-
Notifications
You must be signed in to change notification settings - Fork 0
feat(did): sponsored DID registration on testnet (registry v0.3.0) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -27,6 +28,7 @@ import type { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| VaultSetNewOwnerResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| VaultSetDidResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SponsoredVaultCreateResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DidRegisterSponsoredResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from "./types/api-responses"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Reject self-sponsorship in Direct 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return this.axios | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .post<DidRegisterSponsoredResponse>( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "/contracts/did/register-sponsored", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| payload | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .then((r) => r.data); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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] | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
ActaClientclassifies 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 adid: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