feat(did): sponsored DID registration on testnet (registry v0.3.0) - #9
Conversation
Supports did-stellar-registry.register_sponsored via the API's new
POST /contracts/did/register-sponsored. An organisation pays for a user's
DID without ever controlling it: only the sponsor signs, and
record.controller owns the DID from version 1, so the payer never holds
custody.
Testnet only. Mainnet runs registry v0.2.0, which has no such entrypoint;
calls there are refused locally with register_sponsored_unsupported instead
of burning a round-trip. No existing method changes behaviour, and the
self-service issuer-DID onboarding path is untouched.
- ActaClient.registerSponsoredDid + supportsSponsoredDidRegistration, the
latter reading the /config capability flag and falling back to the network
name on API versions that predate it.
- useSponsoredDid() hook: prepare -> sign -> submit returning { did, txId },
plus key and DID generation helpers.
- generateSponsoredDidKeys returns two DISTINCT Ed25519 keys; the registry
rejects a key reused across verification relationships with duplicate_key.
- Guards on sponsor == controller in the hook, mirroring contract #22, so the
sponsor never signs a transaction the contract will reject.
Documented the two rules the contract cannot enforce: the subject generates
the keys, and the controller address must be validated off-chain because a
wrong one yields a permanently immutable record.
📝 WalkthroughWalkthroughThe SDK adds testnet-only sponsored DID registration. It introduces identity helpers, separate Ed25519 keys, client capability detection, client registration methods, and the ChangesSponsored DID registration
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant Subject
participant useSponsoredDid
participant ActaClient
participant SponsorWallet
participant API
Subject->>useSponsoredDid: generate keys and DID
useSponsoredDid->>ActaClient: registerSponsored(payload)
ActaClient->>API: check sponsored registration support
ActaClient->>API: prepare sponsored DID transaction
API-->>ActaClient: prepared transaction
ActaClient->>SponsorWallet: request sponsor signature
SponsorWallet-->>useSponsoredDid: signed XDR
useSponsoredDid->>ActaClient: submit signed XDR
ActaClient->>API: submit registration
API-->>useSponsoredDid: DID and transaction ID
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/client.ts`:
- Around line 968-979: Stop inferring the network from the base URL in
ActaClient.supportsSponsoredDidRegistration; use ConfigResponse.networkType when
available, otherwise require an explicit configured network for custom URLs and
fail closed when none is authoritative. Update src/client.ts lines 968-979
accordingly, and update src/hooks/useSponsoredDid.ts lines 47-48 to generate the
DID using that same verified network source rather than an independent URL-based
inference.
- Around line 1020-1029: Update registerSponsoredDid to reject prepare payloads
where payload.sponsor equals payload.record.controller before calling
supportsSponsoredDidRegistration, preserving the documented local refusal
behavior. Add a direct ActaClient test covering this self-sponsorship case and
verify no capability check or request proceeds.
In `@src/identity/sponsored-did.ts`:
- Around line 162-165: Update buildSponsoredDidRecord to reject
args.metadataHash when args.metadataUri is undefined before constructing the
record, preserving the existing metadata fields for valid inputs. Add a test
covering metadataHash without metadataUri and assert that the invalid
combination is rejected before the prepare request.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 12b53b5a-5bae-4c0c-aec5-f4ec833d1682
📒 Files selected for processing (11)
CHANGELOG.mdREADME.mddocs/README.mdsrc/client.tssrc/hooks/index.tssrc/hooks/useSponsoredDid.tssrc/identity/index.tssrc/identity/sponsored-did.tssrc/types/api-responses.tstest/client-sponsored-did.test.tstest/sponsored-did.test.ts
| async supportsSponsoredDidRegistration(): Promise<boolean> { | ||
| try { | ||
| const cfg = await this.getConfig(); | ||
| if (typeof cfg.didRegisterSponsoredSupported === "boolean") { | ||
| return cfg.didRegisterSponsoredSupported; | ||
| } | ||
| } catch { | ||
| // /config unreachable: fall through to the network-name heuristic rather | ||
| // than failing the caller's capability check. | ||
| } | ||
| return this.network === "testnet"; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Do not infer the network from a base-URL substring.
ActaClient classifies every URL that does not contain "mainnet" as testnet. A custom mainnet URL can therefore report sponsored registration as supported when the capability flag is absent. The hook then generates a did:stellar:testnet:... DID for that same mainnet client.
src/client.ts#L968-L979: UseConfigResponse.networkTypewhen available. For custom URLs with no authoritative network value, require an explicit network setting or fail closed.src/hooks/useSponsoredDid.ts#L47-L48: Generate the DID from the same verified network source used by the client capability check.
📍 Affects 2 files
src/client.ts#L968-L979(this comment)src/hooks/useSponsoredDid.ts#L47-L48
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/client.ts` around lines 968 - 979, Stop inferring the network from the
base URL in ActaClient.supportsSponsoredDidRegistration; use
ConfigResponse.networkType when available, otherwise require an explicit
configured network for custom URLs and fail closed when none is authoritative.
Update src/client.ts lines 968-979 accordingly, and update
src/hooks/useSponsoredDid.ts lines 47-48 to generate the DID using that same
verified network source rather than an independent URL-based inference.
| ): Promise<DidRegisterSponsoredResponse> { | ||
| if (!(await this.supportsSponsoredDidRegistration())) { | ||
| throw new ActaApiError({ | ||
| status: 501, | ||
| code: "register_sponsored_unsupported", | ||
| message: | ||
| `Sponsored DID registration is not available on ${this.network}. ` + | ||
| "It requires did-stellar-registry v0.3.0 or later, which is deployed on testnet.", | ||
| }); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reject self-sponsorship in registerSponsoredDid.
Direct ActaClient callers can send a prepare payload where payload.sponsor === payload.record.controller. The check in src/hooks/useSponsoredDid.ts only protects hook callers. This bypasses the documented local refusal and sends a request that the contract rejects.
Validate the prepare payload before the capability check. Add a direct-client test for this case.
Proposed fix
): Promise<DidRegisterSponsoredResponse> {
+ if (
+ "sponsor" in payload &&
+ payload.sponsor === payload.record.controller
+ ) {
+ throw new ActaApiError({
+ status: 400,
+ code: "sponsor_is_controller",
+ message: "sponsor must differ from record.controller.",
+ });
+ }
+
if (!(await this.supportsSponsoredDidRegistration())) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ): Promise<DidRegisterSponsoredResponse> { | |
| if (!(await this.supportsSponsoredDidRegistration())) { | |
| throw new ActaApiError({ | |
| status: 501, | |
| code: "register_sponsored_unsupported", | |
| message: | |
| `Sponsored DID registration is not available on ${this.network}. ` + | |
| "It requires did-stellar-registry v0.3.0 or later, which is deployed on testnet.", | |
| }); | |
| } | |
| ): Promise<DidRegisterSponsoredResponse> { | |
| if ( | |
| "sponsor" in payload && | |
| payload.sponsor === payload.record.controller | |
| ) { | |
| throw new ActaApiError({ | |
| status: 400, | |
| code: "sponsor_is_controller", | |
| message: "sponsor must differ from record.controller.", | |
| }); | |
| } | |
| if (!(await this.supportsSponsoredDidRegistration())) { | |
| throw new ActaApiError({ | |
| status: 501, | |
| code: "register_sponsored_unsupported", | |
| message: | |
| `Sponsored DID registration is not available on ${this.network}. ` + | |
| "It requires did-stellar-registry v0.3.0 or later, which is deployed on testnet.", | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/client.ts` around lines 1020 - 1029, Update registerSponsoredDid to
reject prepare payloads where payload.sponsor equals payload.record.controller
before calling supportsSponsoredDidRegistration, preserving the documented local
refusal behavior. Add a direct ActaClient test covering this self-sponsorship
case and verify no capability check or request proceeds.
| ...(args.metadataUri !== undefined ? { metadataUri: args.metadataUri } : {}), | ||
| ...(args.metadataHash !== undefined | ||
| ? { metadataHash: args.metadataHash } | ||
| : {}), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject metadataHash without metadataUri.
buildSponsoredDidRecord emits metadataHash when metadataUri is absent. SponsoredDidRecordInput defines this combination as invalid. The API will reject the prepare request.
Reject this combination before building the record. Add a test for it.
Proposed fix
export function buildSponsoredDidRecord(args: {
controller: string;
keys: GeneratedDidKeys;
services?: readonly SponsoredDidService[];
metadataUri?: string;
metadataHash?: string;
}): SponsoredDidRecordInput {
+ if (args.metadataHash !== undefined && args.metadataUri === undefined) {
+ throw new Error("metadataHash requires metadataUri.");
+ }
+
return {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ...(args.metadataUri !== undefined ? { metadataUri: args.metadataUri } : {}), | |
| ...(args.metadataHash !== undefined | |
| ? { metadataHash: args.metadataHash } | |
| : {}), | |
| export function buildSponsoredDidRecord(args: { | |
| controller: string; | |
| keys: GeneratedDidKeys; | |
| services?: readonly SponsoredDidService[]; | |
| metadataUri?: string; | |
| metadataHash?: string; | |
| }): SponsoredDidRecordInput { | |
| if (args.metadataHash !== undefined && args.metadataUri === undefined) { | |
| throw new Error("metadataHash requires metadataUri."); | |
| } | |
| return { | |
| ...(args.metadataUri !== undefined ? { metadataUri: args.metadataUri } : {}), | |
| ...(args.metadataHash !== undefined | |
| ? { metadataHash: args.metadataHash } | |
| : {}), |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/identity/sponsored-did.ts` around lines 162 - 165, Update
buildSponsoredDidRecord to reject args.metadataHash when args.metadataUri is
undefined before constructing the record, preserving the existing metadata
fields for valid inputs. Add a test covering metadataHash without metadataUri
and assert that the invalid combination is rejected before the prepare request.
Adds support for
did-stellar-registry.register_sponsored(registry v0.3.0, contracts-acta#82) through the API's newPOST /contracts/did/register-sponsored(ACTA-Team/acta-api companion PR).An organisation pays for a user's DID without ever controlling it: only the sponsor signs, and
record.controllerowns the DID from version 1, so the payer never holds custody.Testnet only. Mainnet behaviour is unchanged. Backward compatible and purely additive — no existing method changes, and the self-service issuer-DID onboarding path (
getOrCreateIssuerIdentity) is untouched.Added
ActaClient.registerSponsoredDid(payload)— prepare/submit like every other write.ActaClient.supportsSponsoredDidRegistration()— reads the cached/configcapability flag, falling back to the network name on API versions that predate it, and on/configbeing unreachable.useSponsoredDid()—registerSponsored(prepare → sign → submit, returning{ did, txId }),generateKeys,generateDid,isSupported.generateSponsoredDid,generateSponsoredDidKeys,buildSponsoredDidRecordand their types, from the identity layer.ConfigResponse.didStellarRegistryVersionandConfigResponse.didRegisterSponsoredSupported.Decisions
@acta-team/did-stellardirectly, and that stays.register_sponsoredneeds the installed@acta-team/did-stellar@0.1.0to expose a prepare helper it does not have, and its bundled testnet registry default is still v0.1.0. Routing through the API means the SDK works today without waiting on adid-stellarrelease, and the registry id stays a server-side concern.501. It is driven by the capability flag rather than a hardcoded network check, so a future mainnet registry upgrade needs no SDK release.generateSponsoredDidKeysreturns two DISTINCT Ed25519 keys. The registry enforces that a key appears in at most one verification relationship and rejects reuse withduplicate_key(feat(did): sponsored DID registration on testnet (registry v0.3.0) #9) — the same invariant the issuer-identity provider already guards. UsesgetPublicKeyAsync; the sync variant throwshashes.sha512Sync not setunder @noble/ed25519 v2.sponsor == controlleris rejected in the hook before the sponsor's wallet is asked to sign, mirroring contract#22.Security
Two rules the contract cannot enforce, documented on the module, the client method, the hook,
docs/README.mdand the CHANGELOG:generateSponsoredDidKeysis meant to run on the subject's side; onlypublicKeyMultibaseshould reach the sponsor.controlleroff-chain before registering. It is never proved on-chain, andupdate,transfer_controlleranddeactivateall require its signature. A mistyped address, an address on the wrong network, or a non-existent account yields a permanently immutable record whose only remedy is abandoning the DID.Tests
6 new suites' worth of cases (34 total, all green, no network):
supportsSponsoredDidRegistration: honours the flag, falls back to the network name when the field is absent, and when/configthrows.registerSponsoredDid: refuses on mainnet without issuing a request, posts the prepare payload, posts the signed XDR in submit mode.buildSponsoredDidRecord: one key per relationship, metadata fields omitted when unset (the contract rejects a hash without a URI), services carried through.Docs
useSponsoredDidsection indocs/README.mdwith the security rules, the export table inREADME.md, and a CHANGELOG entry.Depends on
The
acta-apicompanion PR.registerSponsoredDidtargets an endpoint that does not exist until it ships; until then the call returns404.Checklist
Summary by CodeRabbit
did:stellarregistration, allowing sponsors to fund and sign while controllers retain ownership.did:stellarupdates.