Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,51 @@ All notable changes to `@acta-team/credentials` are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased] - sponsored DID registration (testnet)

Backward compatible. Purely additive: no existing method changes behaviour,
and the self-service issuer-DID onboarding path is untouched.

Adds support for `did-stellar-registry.register_sponsored` (registry v0.3.0,
contracts-acta#82) 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` rather
than burning a round-trip. Mainnet behaviour is otherwise unchanged.

### 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.
- `useSponsoredDid()` hook: `registerSponsored` (prepare → sign → submit,
returning `{ did, txId }`), `generateKeys`, `generateDid`, `isSupported`.
- `generateSponsoredDid`, `generateSponsoredDidKeys`,
`buildSponsoredDidRecord` and their types, exported from the identity layer.
`generateSponsoredDidKeys` returns two DISTINCT Ed25519 keys — the registry
rejects a key reused across verification relationships with `duplicate_key`.
- `ConfigResponse.didStellarRegistryVersion` and
`ConfigResponse.didRegisterSponsoredSupported`.

### Security notes

- Keys MUST be generated by the subject, not the sponsor. The registry never
consults the controller address during verification, so a sponsor holding
the private keys would hold signing material for an identity it does not own.
- `record.controller` is never proved on-chain. `update`,
`transfer_controller` and `deactivate` all require its signature, so a wrong
address yields a permanently immutable record whose only remedy is
abandoning the DID. Validate it off-chain before registering.
- `sponsor == record.controller` is rejected client-side, in the hook, and by
the contract (`#22 SponsorIsController`). Sponsoring yourself is plain
registration plus a custody window.

## [1.1.6] - contract (C...) controllers for issuer DID onboarding

Backward compatible.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const status = await verifyVc({ owner, vcId: "badge-001" });
| `useVault` | `createVault`, `denyIssuer`, `allowIssuer` (issuance is open by default; owners block by exception) |
| `useCredential` | `issue`, `revoke` |
| `useVaultRead` | `listVcIds`, `getVc`, `verifyVc` |
| `useSponsoredDid` | `registerSponsored`, `generateKeys`, `generateDid`, `isSupported` (testnet only) |
| `ActaClient` | Everything else: `getConfig`, `vaultSetDid`, `vaultPush`, `vaultSetNewOwner`, `sponsoredVaultCreate`, identity APIs |
| `ActaApiError` / `normalizeError` | Typed errors with stable `code`s, 30s timeouts |
| `mainNet` / `testNet` | Base URL constants (`https://api.{network}.acta.build`); custom URLs accepted |
Expand Down
41 changes: 41 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,47 @@ const verification = await verifyVc({
// Returns: { status: "valid" | "revoked", since?: string }
```

### `useSponsoredDid()` - Sponsored DID Registration (testnet only)

Register a `did:stellar` that your organisation **pays for but does not
control**. Only the sponsor signs; the controller owns the DID from version 1,
so there is no window in which the payer holds custody.

Requires `did-stellar-registry` v0.3.0+, which is deployed on testnet. On
mainnet (registry v0.2.0) the call is refused with
`register_sponsored_unsupported`.

```typescript
const { isSupported, generateKeys, registerSponsored } = useSponsoredDid();

if (!(await isSupported())) return; // gate the UI, do not hardcode the network

// Run this on the SUBJECT's side. Only the public multibase values should
// ever reach the sponsor.
const keys = await generateKeys();

const { did, txId } = await registerSponsored({
sponsor: "G...", // pays and signs
controller: "G...", // owns the DID; MUST differ from sponsor
keys,
signTransaction, // the SPONSOR's wallet
});
```

Two rules the contract cannot enforce for you:

1. **The subject generates the keys.** Verification never consults the
controller address, so a sponsor holding the private keys would hold
signing material for an identity it does not own.
2. **Validate `controller` before calling.** 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 an account that
does not exist produces a permanently immutable record — the only remedy is
to abandon the DID and register a fresh one.

Sponsoring yourself (`sponsor === controller`) is rejected: it is plain
registration plus a custody window.

## Transaction Flow

All operations that modify state follow this flow:
Expand Down
82 changes: 81 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import axios, { AxiosInstance } from "axios";
import { baseURL } from "./types/types";
import { CreateCredentialPayload } from "./types";
import { normalizeError } from "./errors";
import { ActaApiError, normalizeError } from "./errors";
import type { SponsoredDidRecordInput } from "./identity/sponsored-did";
import { IssuerIdentityProvider } from "./identity/provider";
import type {
IssuerIdentity,
Expand All @@ -27,6 +28,7 @@ import type {
VaultSetNewOwnerResponse,
VaultSetDidResponse,
SponsoredVaultCreateResponse,
DidRegisterSponsoredResponse,
} from "./types/api-responses";

/**
Expand Down Expand Up @@ -955,4 +957,82 @@ export class ActaClient {
.then((r) => r.data);
}

/**
* Whether the connected API's network supports sponsored DID registration
* (`did-stellar-registry` v0.3.0+).
*
* Reads the cached `/config`. On API versions that predate the capability
* flag, falls back to the network name — the entrypoint has only ever
* existed on testnet.
*/
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";
}
Comment on lines +968 to +979

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.


/**
* Register a `did:stellar` paid for by a sponsor
* (`POST /contracts/did/register-sponsored`).
*
* Only the sponsor signs. `record.controller` owns the DID from version 1,
* so the payer never holds custody — and for that reason the contract
* rejects `sponsor == record.controller` with `sponsor_is_controller`.
*
* Can prepare an unsigned XDR or submit a signed one, like every other write.
*
* **Testnet only.** Mainnet runs registry v0.2.0, which has no such
* entrypoint; the call is refused locally with
* `register_sponsored_unsupported` instead of burning a round-trip. Check
* {@link supportsSponsoredDidRegistration} first if you branch on it.
*
* SECURITY: `record.controller` is never proved on-chain, and a wrong
* address yields a permanently immutable record. Validate it off-chain, and
* have the subject generate the keys (see `generateSponsoredDidKeys`).
*
* @param payload - Either prepare mode with the sponsorship details, or
* submit mode with the signed XDR.
* @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }`
*/
async registerSponsoredDid(
payload:
| {
/** Sponsor address (G...) that pays the fees and is the only signer. */
sponsor: string;

/** Canonical `did:stellar:{network}:{didId}`. See `generateSponsoredDid`. */
did: string;

/** Initial DID record. `controller` MUST differ from `sponsor`. */
record: SponsoredDidRecordInput;

/** Stellar public key that will sign. Defaults to `sponsor`. */
sourcePublicKey?: string;
}
| { signedXdr: string }
): 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.",
});
}
Comment on lines +1020 to +1029

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.


return this.axios
.post<DidRegisterSponsoredResponse>(
"/contracts/did/register-sponsored",
payload
)
.then((r) => r.data);
}
}
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
export * from "./useVault";
export * from "./useCredential";
export * from "./useVaultRead";
export * from "./useSponsoredDid";
148 changes: 148 additions & 0 deletions src/hooks/useSponsoredDid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useMemo } from "react";
import { useActaClient } from "../providers/ActaClientContext";
import { isTxPrepareResponse, isTxSubmitResponse } from "../types/api-responses";
import {
buildSponsoredDidRecord,
generateSponsoredDid,
generateSponsoredDidKeys,
} from "../identity/sponsored-did";
import type {
GeneratedDidKeys,
SponsoredDidRecordInput,
SponsoredDidService,
} from "../identity/sponsored-did";

/** Function that signs an unsigned XDR with the given network passphrase. */
type Signer = (
unsignedXdr: string,
opts: { networkPassphrase: string }
) => Promise<string>;

/**
* Hook for sponsored `did:stellar` registration.
*
* An organisation pays for a user's DID without ever controlling it: only the
* sponsor signs, and the controller owns the DID from version 1.
*
* **Testnet only** — needs `did-stellar-registry` v0.3.0+. Use
* {@link useSponsoredDid.isSupported} to branch before showing the flow.
*/
export function useSponsoredDid() {
const client = useActaClient();

return useMemo(
() => ({
/**
* Whether the connected network supports sponsored registration.
* Use this to gate the UI instead of hardcoding a network check.
*/
isSupported: () => client.supportsSponsoredDidRegistration(),

/**
* Generate the subject's key material. Run this on the subject's side —
* only the public multibase values should reach the sponsor.
*/
generateKeys: (): Promise<GeneratedDidKeys> => generateSponsoredDidKeys(),

/** Build a fresh canonical `did:stellar` for the connected network. */
generateDid: (): string => generateSponsoredDid(client.getNetwork()),

/**
* Register a DID paid for by `sponsor` and controlled by `controller`.
* Prepares, asks the sponsor's wallet to sign, and submits.
*
* Pass `record` to supply a pre-built record, or `controller` + `keys`
* to have one assembled. Supply `did` to reuse an id you already
* generated; otherwise a fresh one is created and returned.
*
* SECURITY: `controller` is never proved on-chain. `update`,
* `transfer_controller` and `deactivate` all require its signature, so a
* wrong address yields a permanently immutable record with no remedy but
* abandoning the DID. Validate it before calling.
*
* @returns The registered DID and the transaction id.
*/
registerSponsored: async (args: {
/** `G...` account that pays and signs. MUST differ from the controller. */
sponsor: string;

/** Signs the prepared XDR with the sponsor's wallet. */
signTransaction: Signer;

/** Pre-built record. Mutually exclusive with `controller` + `keys`. */
record?: SponsoredDidRecordInput;

/** `G...` account that will own the DID. Used with `keys`. */
controller?: string;

/** Subject's key material. Used with `controller`. */
keys?: GeneratedDidKeys;

/** Reuse an already-generated DID. Defaults to a fresh one. */
did?: string;

/** Optional services to publish in the DID Document. */
services?: readonly SponsoredDidService[];

/** Transaction source. Defaults to `sponsor`. */
sourcePublicKey?: string;
}): Promise<{ did: string; txId: string }> => {
const record =
args.record ??
(args.controller && args.keys
? buildSponsoredDidRecord({
controller: args.controller,
keys: args.keys,
...(args.services ? { services: args.services } : {}),
})
: undefined);

if (!record) {
throw new Error(
"registerSponsored requires either `record`, or `controller` and `keys`."
);
}

// Caught here so the sponsor never signs a transaction the contract
// will reject with `sponsor_is_controller`.
if (record.controller === args.sponsor) {
throw new Error(
"sponsor must differ from record.controller. Sponsoring yourself is plain registration."
);
}

const did = args.did ?? generateSponsoredDid(client.getNetwork());

const prepareResult = await client.registerSponsoredDid({
sponsor: args.sponsor,
did,
record,
...(args.sourcePublicKey
? { sourcePublicKey: args.sourcePublicKey }
: {}),
});

if (!isTxPrepareResponse(prepareResult)) {
throw new Error(
"Failed to prepare sponsored DID registration transaction"
);
}

const signedXdr = await args.signTransaction(prepareResult.xdr, {
networkPassphrase: prepareResult.network,
});

const submitResult = await client.registerSponsoredDid({ signedXdr });

if (!isTxSubmitResponse(submitResult)) {
throw new Error(
"Failed to submit sponsored DID registration transaction"
);
}

return { did, txId: submitResult.tx_id };
},
}),
[client]
);
}
17 changes: 17 additions & 0 deletions src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,20 @@ export {
} from "./storage";

export type { IssuerIdentity, IssuerIdentityStorage, Signer } from "./types";

/**
* Sponsored DID registration (testnet only, `did-stellar-registry` v0.3.0+).
* An organisation pays for a user's DID without ever controlling it.
*/
export {
generateSponsoredDid,
generateSponsoredDidKeys,
buildSponsoredDidRecord,
} from "./sponsored-did";
export type {
SponsoredDidKey,
SponsoredDidService,
SponsoredDidRecordInput,
GeneratedDidKey,
GeneratedDidKeys,
} from "./sponsored-did";
Loading