Skip to content

feat(did): sponsored DID registration on testnet (registry v0.3.0) - #9

Merged
JosueBrenes merged 1 commit into
mainfrom
feat/did-register-sponsored-testnet
Aug 11, 2026
Merged

feat(did): sponsored DID registration on testnet (registry v0.3.0)#9
JosueBrenes merged 1 commit into
mainfrom
feat/did-register-sponsored-testnet

Conversation

@JosueBrenes

@JosueBrenes JosueBrenes commented Aug 11, 2026

Copy link
Copy Markdown
Member

Adds support for did-stellar-registry.register_sponsored (registry v0.3.0, contracts-acta#82) through the API's new POST /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.controller owns 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 /config capability flag, falling back to the network name on API versions that predate it, and on /config being unreachable.
  • useSponsoredDid()registerSponsored (prepare → sign → submit, returning { did, txId }), generateKeys, generateDid, isSupported.
  • generateSponsoredDid, generateSponsoredDidKeys, buildSponsoredDidRecord and their types, from the identity layer.
  • ConfigResponse.didStellarRegistryVersion and ConfigResponse.didRegisterSponsoredSupported.

Decisions

  • This path goes through the REST API, not straight to Soroban. Self-service DID registration uses @acta-team/did-stellar directly, and that stays. register_sponsored needs the installed @acta-team/did-stellar@0.1.0 to 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 a did-stellar release, and the registry id stays a server-side concern.
  • The mainnet refusal is local, so integrators get a clear typed error offline rather than a round-trip and a 501. It is driven by the capability flag rather than a hardcoded network check, so a future mainnet registry upgrade needs no SDK release.
  • generateSponsoredDidKeys returns two DISTINCT Ed25519 keys. The registry enforces that a key appears in at most one verification relationship and rejects reuse with duplicate_key (feat(did): sponsored DID registration on testnet (registry v0.3.0) #9) — the same invariant the issuer-identity provider already guards. Uses getPublicKeyAsync; the sync variant throws hashes.sha512Sync not set under @noble/ed25519 v2.
  • sponsor == controller is 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.md and the CHANGELOG:

  1. The subject generates the keys, not the sponsor. Verification never consults the controller address, so a sponsor holding the private keys would hold signing material for an identity it does not own. generateSponsoredDidKeys is meant to run on the subject's side; only publicKeyMultibase should reach the sponsor.
  2. Validate controller off-chain before registering. It is never proved on-chain, and update, transfer_controller and deactivate all 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 /config throws.
  • registerSponsoredDid: refuses on mainnet without issuing a request, posts the prepare payload, posts the signed XDR in submit mode.
  • Key generation: two distinct keys, async path enforced (the sync mock throws the real v2 error), hex/multibase shapes.
  • buildSponsoredDidRecord: one key per relationship, metadata fields omitted when unset (the contract rejects a hash without a URI), services carried through.

Docs

useSponsoredDid section in docs/README.md with the security rules, the export table in README.md, and a CHANGELOG entry.

Depends on

The acta-api companion PR. registerSponsoredDid targets an endpoint that does not exist until it ships; until then the call returns 404.

Checklist

  • Tests added
  • Docs updated (docs/README, README, CHANGELOG)
  • Backward compatible — purely additive
  • Mainnet behaviour unchanged

Summary by CodeRabbit

  • New Features
    • Added testnet-only sponsored did:stellar registration, allowing sponsors to fund and sign while controllers retain ownership.
    • Added DID generation, key generation, record-building helpers, support detection, and React integration.
    • Added vault DID assignment and issuer allow/deny controls.
  • Documentation
    • Documented sponsored registration requirements, supported networks, controller validation, and issuer onboarding.
    • Added guidance for vault, credential issuance, and did:stellar updates.
  • Deprecations
    • Retained deprecated APIs temporarily; they are scheduled for removal in version 2.0.0.
  • Bug Fixes
    • Improved validation, error handling, timeouts, key security, caching, and runtime configuration behavior.

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.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The SDK adds testnet-only sponsored DID registration. It introduces identity helpers, separate Ed25519 keys, client capability detection, client registration methods, and the useSponsoredDid React hook with validation and signing flows.

Changes

Sponsored DID registration

Layer / File(s) Summary
Sponsored DID identity records
src/identity/sponsored-did.ts, src/identity/index.ts, test/sponsored-did.test.ts
The SDK defines sponsored DID types, generates separate authentication and assertion keys asynchronously, builds DID records, and tests canonical identifiers, key encoding, and record relationships.
Client capability and registration API
src/types/api-responses.ts, src/client.ts, test/client-sponsored-did.test.ts
The client reads configuration capabilities, falls back to testnet detection, rejects unsupported networks with a typed error, and submits prepared or signed sponsored registration requests.
React hook and public usage
src/hooks/useSponsoredDid.ts, src/hooks/index.ts, README.md, docs/README.md, CHANGELOG.md
useSponsoredDid exposes support checks, key and DID generation, record validation, sponsor signing, and registration. Public documentation describes the testnet flow and constraints.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

  • ACTA-Team/acta-credentials#3 — Both changes update client, identity/DID functionality, and API types for related issuer and did:stellar onboarding flows.
  • ACTA-Team/acta-credentials#4 — Both changes use shared sponsored-DID and asynchronous Ed25519 public-key generation functionality.

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the sponsored DID registration feature and its testnet scope.
Description check ✅ Passed The description explains the change, design decisions, security constraints, tests, documentation, compatibility, and dependency on the companion API PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/did-register-sponsored-testnet

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between af6df28 and 0ff36aa.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • README.md
  • docs/README.md
  • src/client.ts
  • src/hooks/index.ts
  • src/hooks/useSponsoredDid.ts
  • src/identity/index.ts
  • src/identity/sponsored-did.ts
  • src/types/api-responses.ts
  • test/client-sponsored-did.test.ts
  • test/sponsored-did.test.ts

Comment thread src/client.ts
Comment on lines +968 to +979
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";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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: Use ConfigResponse.networkType when 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.

Comment thread src/client.ts
Comment on lines +1020 to +1029
): 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.",
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
): 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.

Comment on lines +162 to +165
...(args.metadataUri !== undefined ? { metadataUri: args.metadataUri } : {}),
...(args.metadataHash !== undefined
? { metadataHash: args.metadataHash }
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
...(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.

@JosueBrenes
JosueBrenes merged commit 0191d83 into main Aug 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant