diff --git a/CHANGELOG.md b/CHANGELOG.md index 0252260..78621c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,116 @@ 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). +## [1.1.2] - 2026-05-19 — Align with `acta-api` v1.2.0 / `vc-vault` v0.3.0 + +Brings the SDK in line with `acta-api` v1.2.0 (which itself aligns with +`vc-vault` v0.3.0 and exposes `vc-issuer-registry`). Major surface +refresh: new methods, typed errors, paginated reads, and React hooks for +every endpoint the API now ships. + +> **Versioning note.** Strict semver would call most of these changes +> BREAKING and bump to 2.0.0. We are intentionally staying on `1.1.x` +> until the panel and other internal consumers have migrated. **Do not +> publish from this branch.** When the migration is ready, cut a major +> bump in a separate release. + +### Removed — BREAKING + +- `ActaClient.createCredential` — was already deprecated and threw at + call time. +- `ActaClient.getDefaults` — was a deprecated alias for `getConfig` that + also remapped to the legacy `issuanceContractId` / `vaultContractId` + fields. Use `getConfig()` directly. +- `ActaClient.prepareStoreTx`, `prepareListVcIdsTx`, `prepareGetVcTx`, + `vaultStore` — were all deprecated wrappers that either threw or + duplicated `vcIssue` / `vaultListVcIdsDirect` / `vaultGetVcDirect`. +- `ActaClient.vaultMigrate` and the `VaultMigrateResponse` type — the + corresponding `POST /contracts/vault/migrate` endpoint was removed from + `acta-api` v1.2.0 (the contract no longer exposes `migrate`). +- `CreateCredentialPayload` / `CreateCredentialResponse` types and the + `src/types/type.payload.ts` and `src/types/types.response.ts` files. + +### Changed — BREAKING + +- `ActaClient.vaultListVcIdsDirect` now accepts `offset` and `limit` + (defaults 0 / 50, max 200 = `MAX_LIST_LIMIT`). The API endpoint became + paginated in `acta-api` v1.2.0; calls without these args used to + succeed; from this release they hit the paginated path. +- `ActaClient.revokeCredentialViaApi` now requires `owner: string` in + prepare mode. The `vc-vault` contract calls `owner.require_auth()`, so + the vault owner MUST sign the prepared XDR (relayer signatures no + longer satisfy authorisation). +- `useCredential().revoke({ owner, ... })` — same change at the hook + level. Smart-account (C...) owners are rejected client-side because the + contract's auth path is not modelled for them yet. +- `VaultVerifyVcResponse.status` is now typed `"valid" | "revoked" | + "invalid" | "unknown"`. The previous `"valid" | "revoked"` was + incomplete; the API has returned `"invalid"` since v1.2.0. +- All `ActaClient` methods now reject with `ActaError` on non-2xx + responses instead of axios's generic error object. The shape exposes + `code`, `httpStatus`, `requestId`, `retryAfter` and `details`. + +### Added + +- `ActaClient.vcBatchIssue` — issues up to 5 VCs in one transaction + (`MAX_BATCH_SIZE = 5`). Mirrors `POST /contracts/vc/batch-issue`. +- `ActaClient.vaultVcCount` — O(1) active-VC count. Pair with + `vaultListVcIdsDirect` to size pagination without polling for empty + pages. +- `ActaClient.vaultListAuthorizedIssuers`, `vaultListDeniedIssuers`, + `vaultAuthorizedIssuerCount`, `vaultDeniedIssuerCount` — per-vault + issuer lists + O(1) counts. +- `ActaClient.vaultMetadata` — combined vault metadata (`admin`, + `did_uri`, `revoked`, `vc_count`, `authorized_issuer_count`) in one + round-trip. +- Issuer-registry methods: `issuerRegistryAdd`, `issuerRegistrySetMetadata`, + `issuerRegistrySetAllowed`, `issuerRegistryRemove`, `issuerRegistryGet`, + `issuerRegistryIsAllowed`, `issuerRegistryStatus`. Until + `vc-issuer-registry` is deployed, these reject with `contractId_invalid`. +- New React hooks: + - `useCredential().batchIssue(...)` — prepare → sign → submit for up to + 5 VCs. + - `useVaultRead().vcCount(...)`, `listAllVcIds(...)`, `metadata(...)`. + - `useVaultIssuers()` — `count`, `countDenied`, `list`, `listDenied`, + `listAll`, `listAllDenied`. + - `useIssuerRegistry()` — read (`get`, `isAllowed`, `status`) and admin + mutations (`add`, `setMetadata`, `setAllowed`, `remove`). + - `useVault().authorizeIssuers(...)` — bulk variant (validated against + `MAX_ISSUERS_LIST = 100`). +- `ActaError` typed error class (`src/utils/acta-error.ts`) + the + `ActaErrorCode` string-literal union of every known API error code + (vc-vault + vc-issuer-registry + API-side). +- `CONTRACT_LIMITS` constants (`src/utils/contract-limits.ts`): mirror of + the contract-side caps so UI inputs can validate locally. +- `index.ts` now also re-exports `ActaClient`, `ActaError`, + `CONTRACT_LIMITS` and their TS types for consumers that want them + directly. + +### Internal + +- Response shape `VaultListVcIdsResponse` gained `offset` and `limit` + echo fields. +- New shapes: `VaultVcCountResponse`, `VaultIssuerListResponse`, + `VaultIssuerCountResponse`, `VaultMetadataResponse`, `VcBatchIssueResponse`, + `IssuerRecord`, `IssuerRegistry*Response`. +- `useCredential` hook lost its `useCreateCredential` parity; the deleted + legacy helpers were never wired to a hook anyway. + +### Migration guide + +1. Stop importing the removed methods (`createCredential`, + `prepareStoreTx`, `prepareListVcIdsTx`, `prepareGetVcTx`, `vaultStore`, + `getDefaults`, `vaultMigrate`). +2. Pass `owner` to `revokeCredentialViaApi` / `useCredential().revoke`, + and ensure the resulting XDR is signed by that owner. +3. If you call `vaultListVcIdsDirect`, accept the new defaults (`offset=0`, + `limit=50`) or pass explicit values. For full lists, prefer + `useVaultRead().listAllVcIds(...)`. +4. Catch `ActaError` and branch on `err.code` instead of parsing + `err.response?.data`. +5. Narrow `verifyVc` results against the new `"invalid"` / `"unknown"` + variants when using TypeScript's strictness. + ## [1.1.1] - 2026-05-07 First release under the new package name. Previously published as diff --git a/package.json b/package.json index 4f95137..ca43ac3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@acta-team/credentials", - "version": "1.1.1", - "description": "ACTA credentials SDK for Stellar", + "version": "1.1.2", + "description": "ACTA credentials SDK for Stellar (aligned with acta-api v1.2.0 / vc-vault v0.3.0).", "type": "commonjs", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/client.ts b/src/client.ts index aac8bee..1aa808f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,39 +1,53 @@ import axios, { AxiosInstance } from "axios"; import { baseURL } from "./types/types"; -import { CreateCredentialPayload, CreateCredentialResponse } from "./types"; import type { ConfigResponse, HealthResponse, TxPrepareResponse, - TxSubmitResponse, VaultCreateResponse, VaultAuthorizeIssuerResponse, + VaultAuthorizeIssuersResponse, VaultRevokeIssuerResponse, VaultRevokeVaultResponse, + VaultSetNewOwnerResponse, + VaultPushResponse, VcIssueResponse, VcIssueLinkedResponse, + VcBatchIssueResponse, VcRevokeResponse, VaultListVcIdsResponse, VaultGetVcResponse, + VaultGetVcParentResponse, VaultVerifyVcResponse, + VaultVcCountResponse, + VaultIssuerListResponse, + VaultIssuerCountResponse, + VaultMetadataResponse, ContractVersionResponse, - VaultMigrateResponse, - VaultPushResponse, - VaultSetNewOwnerResponse, - VaultAuthorizeIssuersResponse, SponsoredVaultCreateResponse, SponsoredVaultSetOpenToAllResponse, SponsoredVaultAddSponsorResponse, SponsoredVaultRemoveSponsorResponse, SponsoredVaultOpenToAllReadResponse, - VaultGetVcParentResponse, + IssuerRegistryAddResponse, + IssuerRegistrySetMetadataResponse, + IssuerRegistrySetAllowedResponse, + IssuerRegistryRemoveResponse, + IssuerRecord, + IssuerRegistryIsAllowedResponse, + IssuerRegistryStatusResponse, } from "./types/api-responses"; +import { actaErrorFromAxios } from "./utils/acta-error"; /** * ACTA SDK HTTP client. * - * Wraps ACTA API endpoints to issue, store, read, and verify credentials, - * and to prepare transactions. The network is inferred from the `baseURL`. + * Wraps the ACTA API endpoints to issue, store, read and verify + * credentials, manage vaults, and (since v1.1.2) work with the + * `vc-issuer-registry`. The active network is inferred from the `baseURL`. + * + * All methods reject with an {@link ActaError} on non-2xx responses so + * callers can branch on `err.code` instead of substring-matching messages. */ export class ActaClient { private axios: AxiosInstance; @@ -43,30 +57,20 @@ export class ActaClient { /** * Initialize a new client instance. * @param baseURL - Base API URL for ACTA services (mainnet or testnet). - * @param apiKey - API key for authentication. If not provided, will be read from environment variables. - * @throws Error if API key is not provided and environment variable is not set for the network. - * - * The API key is required and must be provided either as a parameter or via environment variables. - * It is automatically configured in the X-ACTA-Key header for all requests. - * - * API keys are network-specific. Set in your .env file: - * - ACTA_API_KEY_MAINNET=your-mainnet-api-key (for mainnet) - * - ACTA_API_KEY_TESTNET=your-testnet-api-key (for testnet) - * - * Or use ACTA_API_KEY as fallback for both networks: - * - ACTA_API_KEY=your-api-key (works for both networks) + * @param apiKey - API key for authentication. If not provided, read from + * `ACTA_API_KEY_MAINNET` / `ACTA_API_KEY_TESTNET` (network-specific) + * or `ACTA_API_KEY` (fallback). + * @throws Error if no API key is found. */ constructor(baseURL: baseURL, apiKey?: string) { this.axios = axios.create({ baseURL }); this.network = baseURL.includes("mainnet") ? "mainnet" : "testnet"; - // Use provided API key, or read from environment variable (network-specific or fallback) const env = typeof process !== "undefined" ? process.env : {}; const networkSpecificKey = this.network === "mainnet" ? env.ACTA_API_KEY_MAINNET : env.ACTA_API_KEY_TESTNET; - const finalApiKey = apiKey || networkSpecificKey || env.ACTA_API_KEY; if (!finalApiKey || finalApiKey.trim() === "") { @@ -74,7 +78,6 @@ export class ActaClient { this.network === "mainnet" ? "ACTA_API_KEY_MAINNET" : "ACTA_API_KEY_TESTNET"; - throw new Error( `API key is required for ${this.network}.\n` + `Provide it as a parameter or set it in your .env file:\n` + @@ -86,108 +89,65 @@ export class ActaClient { ); } - // Configure interceptor to automatically add API key header to all requests + // Inject X-ACTA-Key on every request. this.axios.interceptors.request.use((config) => { config.headers = config.headers || {}; config.headers["X-ACTA-Key"] = finalApiKey.trim(); return config; }); - } - /** - * @deprecated Use vcIssue endpoint instead. This method is kept for backward compatibility but will be removed. - * Create a new credential - */ - createCredential(data: CreateCredentialPayload) { - throw new Error( - "createCredential is deprecated. Use vcIssue endpoint via useCreateCredential hook instead." + // Convert any non-2xx into a typed ActaError so consumers can branch + // on `err.code` instead of parsing `err.response?.data?.error`. + this.axios.interceptors.response.use( + (response) => response, + (error) => { + return Promise.reject(actaErrorFromAxios(error)); + } ); } - /** - * Get the network type (mainnet or testnet). - * @returns Network type: "mainnet" or "testnet" - */ + // ------------------------------------------------------------------------- + // Bootstrap + // ------------------------------------------------------------------------- + + /** Network type (mainnet or testnet). */ getNetwork(): "mainnet" | "testnet" { return this.network; } - /** - * Get service health status. - * @returns Service status, timestamp, and environment info. - */ + /** Service health. */ getHealth(): Promise { return this.axios.get("/health").then((r) => r.data); } /** - * Get configuration from the API. - * @returns Configuration: `rpcUrl`, `networkPassphrase`, `actaContractId`. - * @throws Error if the API is unavailable. + * Read `/config`. Cached for the lifetime of the client to avoid an + * RTT before every signing flow. */ async getConfig(): Promise { - if (this.configCache) { - return this.configCache; - } - + if (this.configCache) return this.configCache; const response = await this.axios.get("/config"); - this.configCache = response.data; return this.configCache; } - /** - * Get default runtime configuration from the API. - * @deprecated Use `getConfig()` instead. This method is kept for backward compatibility. - * @returns Configuration from the API: `rpcUrl`, `networkPassphrase`, `actaContractId`. - */ - async getDefaults() { - const config = await this.getConfig(); - return { - rpcUrl: config.rpcUrl, - networkPassphrase: config.networkPassphrase, - actaContractId: config.actaContractId, - // Legacy support - map to actaContractId - issuanceContractId: config.actaContractId, - vaultContractId: config.actaContractId, - }; - } + // ------------------------------------------------------------------------- + // Convenience: prepare-only helper for issuing + // ------------------------------------------------------------------------- /** - * Prepare an unsigned XDR to issue a credential (which stores it in the vault). - * Uses the same endpoint as vcIssue but only prepares the transaction. - * @param args - Arguments describing the credential details: - * - owner: Stellar account address (public key) that owns the credential vault - * - vcId: Unique identifier for the credential - * - vcData: JSON string containing the credential data/claims. MUST include "@context" field with at least: - * ["https://www.w3.org/ns/credentials/v2", "https://www.w3.org/ns/credentials/examples/v2"] - * - issuer: Stellar account address (public key) of the credential issuer (who creates the credential) - * - issuerDid: Optional issuer DID; if omitted the API derives one from the issuer address - * - sourcePublicKey: Optional signer account (defaults to issuer for G-address owners) - * - contractId: Optional contract ID (defaults to network contract) - * @returns `{ xdr, network }` to be signed by the caller. + * Prepare an unsigned XDR to issue a credential. Thin wrapper over + * {@link ActaClient.vcIssue}'s prepare mode that returns just + * `{ xdr, network }` and throws if the server somehow returned a submit + * response by mistake. */ prepareIssueTx(args: { - /** Stellar account address (public key) that owns the credential vault */ owner: string; - - /** Unique identifier for the credential */ vcId: string; - - /** JSON string containing the credential data/claims. MUST include "@context" field with at least: ["https://www.w3.org/ns/credentials/v2", "https://www.w3.org/ns/credentials/examples/v2"] */ vcData: string; - - /** Stellar account address (public key) of the credential issuer (who creates the credential) */ issuer: string; - - /** DID of the issuer in format did:pkh:network:walletAddress */ issuerDid?: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; - - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted and owner is a smart account (C...), the backend uses the relayer. */ sourcePublicKey?: string; }): Promise { return this.vcIssue(args).then((r) => { @@ -199,128 +159,22 @@ export class ActaClient { "Failed to prepare transaction: missing xdr or network" ); } - return { - xdr: r.xdr, - network: r.network, - }; + return { xdr: r.xdr, network: r.network }; }); } - /** - * @deprecated Use vcIssue endpoint directly. This method is kept for backward compatibility. - * Prepare an unsigned XDR to store a credential in the Vault. - * Note: Storing is done via vcIssue which stores and marks as valid. - */ - prepareStoreTx(args: { - /** Stellar account address (public key) that owns the credential vault */ - owner: string; - - /** Unique identifier for the credential */ - vcId: string; - - /** DID URI of the credential owner */ - didUri: string; - - /** Credential data fields */ - fields: Record; - - /** Optional vault contract ID (defaults to network contract) */ - vaultContractId?: string; - - /** Optional Stellar account address (public key) of the credential issuer */ - issuer?: string; - }) { - // Store is handled by vcIssue, so we redirect to that - if (!args.issuer) { - throw new Error("Issuer is required to issue/store a credential"); - } - return this.prepareIssueTx({ - owner: args.owner, - vcId: args.vcId, - vcData: JSON.stringify(args.fields), - issuer: args.issuer, - contractId: args.vaultContractId, - sourcePublicKey: args.owner, - }); - } + // ------------------------------------------------------------------------- + // Vault — reads + // ------------------------------------------------------------------------- /** - * @deprecated These are read operations, not prepare operations. Use vaultListVcIdsDirect instead. - * List VC IDs from the Vault (read operation, no XDR needed). - */ - prepareListVcIdsTx(args: { - /** Stellar account address (public key) that owns the credential vault */ - owner: string; - - /** Optional vault contract ID (defaults to network contract) */ - vaultContractId?: string; - }) { - return this.vaultListVcIdsDirect(args).then(() => { - throw new Error( - "prepareListVcIdsTx is deprecated. Use vaultListVcIdsDirect for read operations." - ); - }); - } - - /** - * @deprecated These are read operations, not prepare operations. Use vaultGetVcDirect instead. - * Fetch a VC from the Vault (read operation, no XDR needed). - */ - prepareGetVcTx(args: { - /** Stellar account address (public key) that owns the credential vault */ - owner: string; - - /** Unique identifier for the credential */ - vcId: string; - - /** Optional vault contract ID (defaults to network contract) */ - vaultContractId?: string; - }) { - return this.vaultGetVcDirect(args).then(() => { - throw new Error( - "prepareGetVcTx is deprecated. Use vaultGetVcDirect for read operations." - ); - }); - } - - /** - * @deprecated Storing is handled automatically by vcIssue. Use vcIssue endpoint instead. - * Submit a signed XDR to store a credential in the Vault. - */ - vaultStore(payload: { - /** Signed XDR transaction string */ - signedXdr: string; - - /** Unique identifier for the credential */ - vcId: string; - - /** Optional Stellar account address (public key) that owns the credential vault */ - owner?: string; - - /** Optional vault contract ID (defaults to network contract) */ - vaultContractId?: string; - }) { - throw new Error( - "vaultStore is deprecated. Credentials are stored automatically when issued via vcIssue. Use useIssueCredential hook instead." - ); - } - - /** - * Verify a credential against the Vault contract. - * @param args - Credential verification details - * @returns Verification result with `status` and optional `since`. + * Verify a credential against the vault contract. + * Returns one of `valid` / `revoked` / `invalid` / `unknown`. */ vaultVerify(args: { - /** Stellar account address (public key) that owns the credential vault */ owner: string; - - /** Unique identifier for the credential */ vcId: string; - - /** Optional vault contract ID (defaults to network contract) */ vaultContractId?: string; - - /** Optional contract ID (defaults to network contract, alternative to vaultContractId) */ contractId?: string; }): Promise { const contractId = args.vaultContractId || args.contractId; @@ -334,45 +188,33 @@ export class ActaClient { } /** - * List credential IDs directly from the Vault contract. - * @param args - Vault listing details - * @returns `{ vc_ids }` or `{ result }` with IDs. + * Paginated list of VC IDs in an owner's vault. `offset` / `limit` + * default to `0` / `50`. `limit` is capped at 200 by the API and the + * contract (`MAX_LIST_LIMIT`). */ vaultListVcIdsDirect(args: { - /** Stellar account address (public key) that owns the credential vault */ owner: string; - - /** Optional vault contract ID (defaults to network contract) */ + offset?: number; + limit?: number; vaultContractId?: string; - - /** Optional contract ID (defaults to network contract, alternative to vaultContractId) */ contractId?: string; }): Promise { const contractId = args.vaultContractId || args.contractId; return this.axios .post("/contracts/vault/list-vc-ids", { owner: args.owner, + offset: args.offset, + limit: args.limit, contractId, }) .then((r) => r.data); } - /** - * Read a credential directly from the Vault contract. - * @param args - Credential retrieval details - * @returns `{ vc }` or `{ result }` with credential contents. - */ + /** Read a credential payload directly from the vault. */ vaultGetVcDirect(args: { - /** Stellar account address (public key) that owns the credential vault */ owner: string; - - /** Unique identifier for the credential */ vcId: string; - - /** Optional vault contract ID (defaults to network contract) */ vaultContractId?: string; - - /** Optional contract ID (defaults to network contract, alternative to vaultContractId) */ contractId?: string; }): Promise { const contractId = args.vaultContractId || args.contractId; @@ -385,22 +227,11 @@ export class ActaClient { .then((r) => r.data); } - /** - * Get the parent VC info for a linked credential (`POST /contracts/vault/get-vc-parent`). - * @param args - Credential lookup details - * @returns `{ parent }` with owner and `vc_id`, or `{ parent: null }` if no parent link. - */ + /** Parent info for a linked credential. `null` if not linked. */ vaultGetVcParent(args: { - /** Stellar account address (public key) that owns the credential vault */ owner: string; - - /** Unique identifier for the credential */ vcId: string; - - /** Optional vault contract ID (defaults to network contract) */ vaultContractId?: string; - - /** Optional contract ID (defaults to network contract, alternative to vaultContractId) */ contractId?: string; }): Promise { const contractId = args.vaultContractId || args.contractId; @@ -413,26 +244,116 @@ export class ActaClient { .then((r) => r.data); } + /** O(1) count of active VCs in an owner's vault. */ + vaultVcCount(args: { + owner: string; + contractId?: string; + }): Promise { + const params: Record = { owner: args.owner }; + if (args.contractId) params.contractId = args.contractId; + return this.axios + .get("/contracts/vault/vc-count", { params }) + .then((r) => r.data); + } + + /** Paginated list of issuers currently authorised in `owner`'s vault. */ + vaultListAuthorizedIssuers(args: { + owner: string; + offset?: number; + limit?: number; + contractId?: string; + }): Promise { + return this.getIssuerList("authorized", args); + } + + /** Paginated list of issuers currently denied (revoked) in `owner`'s vault. */ + vaultListDeniedIssuers(args: { + owner: string; + offset?: number; + limit?: number; + contractId?: string; + }): Promise { + return this.getIssuerList("denied", args); + } + + /** O(1) count of authorised issuers in `owner`'s vault. */ + vaultAuthorizedIssuerCount(args: { + owner: string; + contractId?: string; + }): Promise { + return this.getIssuerCount("authorized", args); + } + + /** O(1) count of denied issuers in `owner`'s vault. */ + vaultDeniedIssuerCount(args: { + owner: string; + contractId?: string; + }): Promise { + return this.getIssuerCount("denied", args); + } + /** - * Create (initialize) a vault for an owner via the API. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with vault creation details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` + * Combined vault metadata: `{ admin, did_uri, revoked, vc_count, + * authorized_issuer_count }`. Saves the client four round-trips. */ + vaultMetadata(args: { + owner: string; + contractId?: string; + }): Promise { + const params: Record = {}; + if (args.contractId) params.contractId = args.contractId; + return this.axios + .get( + `/contracts/vault/${encodeURIComponent(args.owner)}`, + { params } + ) + .then((r) => r.data); + } + + private getIssuerList( + bucket: "authorized" | "denied", + args: { + owner: string; + offset?: number; + limit?: number; + contractId?: string; + } + ): Promise { + const params: Record = { owner: args.owner }; + if (args.offset !== undefined) params.offset = String(args.offset); + if (args.limit !== undefined) params.limit = String(args.limit); + if (args.contractId) params.contractId = args.contractId; + return this.axios + .get(`/contracts/vault/issuers/${bucket}`, { + params, + }) + .then((r) => r.data); + } + + private getIssuerCount( + bucket: "authorized" | "denied", + args: { owner: string; contractId?: string } + ): Promise { + const params: Record = { owner: args.owner }; + if (args.contractId) params.contractId = args.contractId; + return this.axios + .get( + `/contracts/vault/issuers/${bucket}/count`, + { params } + ) + .then((r) => r.data); + } + + // ------------------------------------------------------------------------- + // Vault — mutations + // ------------------------------------------------------------------------- + vaultCreate( payload: | { - /** Stellar account address (public key) that will own the vault */ owner: string; - - /** DID URI of the vault owner */ didUri: string; - - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted and owner is a smart account (C...), the backend uses the relayer. */ sourcePublicKey?: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -442,26 +363,12 @@ export class ActaClient { .then((r) => r.data); } - /** - * Authorize an issuer in a vault via the API. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with authorization details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ vaultAuthorizeIssuer( payload: | { - /** Stellar account address (public key) that owns the vault */ owner: string; - - /** Stellar account address (public key) of the issuer to authorize */ issuer: string; - - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted and owner is a smart account (C...), the backend uses the relayer. */ sourcePublicKey?: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -474,52 +381,47 @@ export class ActaClient { .then((r) => r.data); } - /** - * Revoke a credential via the API. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with revocation details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ - revokeCredentialViaApi( + vaultAuthorizeIssuers( payload: | { - /** Unique identifier for the credential to revoke */ - vcId: string; - - /** Optional revocation date (ISO timestamp, defaults to current time) */ - date?: string; + owner: string; + issuers: string[]; + sourcePublicKey: string; + contractId?: string; + } + | { signedXdr: string } + ): Promise { + return this.axios + .post( + "/contracts/vault/authorize-issuers", + payload + ) + .then((r) => r.data); + } - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted, the backend uses the relayer when configured. */ + vaultRevokeIssuerViaApi( + payload: + | { + owner: string; + issuer: string; sourcePublicKey?: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } - ): Promise { + ): Promise { return this.axios - .post("/contracts/vc/revoke", payload) + .post( + "/contracts/vault/revoke-issuer", + payload + ) .then((r) => r.data); } - /** - * Revoke (disable) a vault for an owner via the API. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with vault revocation details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ vaultRevokeVault( payload: | { - /** Stellar account address (public key) that owns the vault */ owner: string; - - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted and owner is a smart account (C...), the backend uses the relayer. */ sourcePublicKey?: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -529,76 +431,67 @@ export class ActaClient { .then((r) => r.data); } - /** - * Revoke (remove) an authorized issuer from a vault via the API. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with issuer revocation details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ - vaultRevokeIssuerViaApi( + /** Transfer the vault admin role to a new G... address. */ + vaultSetNewOwner( payload: | { - /** Stellar account address (public key) that owns the vault */ owner: string; + newOwner: string; + sourcePublicKey: string; + contractId?: string; + } + | { signedXdr: string } + ): Promise { + if ("signedXdr" in payload) { + return this.axios + .post( + "/contracts/vault/set-new-owner", + payload + ) + .then((r) => r.data); + } + return this.axios + .post("/contracts/vault/set-new-owner", { + owner: payload.owner, + new_owner: payload.newOwner, + contractId: payload.contractId, + sourcePublicKey: payload.sourcePublicKey, + }) + .then((r) => r.data); + } - /** Stellar account address (public key) of the issuer to revoke */ + /** Move a VC from one owner's vault to another. */ + vaultPush( + payload: + | { + fromOwner: string; + toOwner: string; + vcId: string; issuer: string; - - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted and owner is a smart account (C...), the backend uses the relayer. */ - sourcePublicKey?: string; - - /** Optional contract ID (defaults to network contract) */ + sourcePublicKey: string; contractId?: string; } | { signedXdr: string } - ): Promise { + ): Promise { return this.axios - .post( - "/contracts/vault/revoke-issuer", - payload - ) + .post("/contracts/vault/push", payload) .then((r) => r.data); } - /** - * Issue a credential via the API (stores in vault and marks as valid). - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with credential details, or submit mode with signed XDR: - * - owner: Stellar account address (public key) that owns the credential vault - * - vcId: Unique identifier for the credential - * - vcData: JSON string containing the credential data/claims. MUST include "@context" field with at least: - * ["https://www.w3.org/ns/credentials/v2", "https://www.w3.org/ns/credentials/examples/v2"] - * - issuer: Stellar account address (public key) of the credential issuer (who creates the credential) - * - issuerDid: Optional issuer DID; if omitted the API derives one from the issuer address - * - sourcePublicKey: Stellar public key that will sign the transaction (optional for contract owners; defaults to issuer for G-address owners) - * - contractId: Optional contract ID (defaults to network contract) - * - signedXdr: For submit mode, the signed XDR transaction string - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ + // ------------------------------------------------------------------------- + // VC — mutations + // ------------------------------------------------------------------------- + + /** Issue a credential (stores in vault and marks as valid). */ vcIssue( payload: | { - /** Stellar account address (public key) that owns the credential vault */ owner: string; - - /** Unique identifier for the credential */ vcId: string; - - /** JSON string containing the credential data/claims. MUST include "@context" field with at least: ["https://www.w3.org/ns/credentials/v2", "https://www.w3.org/ns/credentials/examples/v2"] */ vcData: string; - - /** Stellar account address (public key) of the credential issuer (who creates the credential) */ issuer: string; - - /** DID of the issuer in format did:pkh:network:walletAddress */ issuerDid?: string; - - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted and owner is a smart account (C...), the backend uses the relayer. */ sourcePublicKey?: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -608,41 +501,18 @@ export class ActaClient { .then((r) => r.data); } - /** - * Issue a linked credential via the API (`POST /contracts/vc/issue-linked`). - * Same prepare/submit flow as {@link ActaClient.vcIssue}, with `parentOwner` / `parentVcId`. - * @param payload - Either prepare mode with credential + parent details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ + /** Issue a credential linked to a parent VC in another vault. */ vcIssueLinked( payload: | { - /** Stellar account address (public key) that owns the credential vault */ owner: string; - - /** Unique identifier for the credential */ vcId: string; - - /** JSON string containing the credential data/claims. MUST include "@context" field */ vcData: string; - - /** Stellar account address (public key) of the credential issuer */ issuer: string; - - /** DID of the issuer in format did:pkh:network:walletAddress */ issuerDid?: string; - - /** Stellar public key that will sign the transaction (G...). - * Optional: when omitted and owner is a smart account (C...), the backend uses the relayer. */ sourcePublicKey?: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; - - /** Stellar account address (public key) of the parent VC owner */ parentOwner: string; - - /** Identifier of the parent VC */ parentVcId: string; } | { signedXdr: string } @@ -653,179 +523,76 @@ export class ActaClient { } /** - * Read the ACTA contract version string. - * @param args - Optional contract and source configuration - * @returns `{ version }` with the contract version. + * Issue up to 5 VCs in a single transaction (`MAX_BATCH_SIZE`). The + * contract takes one `require_auth` and one fee transfer for the whole + * batch. */ - getContractVersion(args?: { - /** Optional contract ID override (defaults to network contract) */ - contractId?: string; - - /** Optional source public key used for Soroban simulation */ - sourcePublicKey?: string; - }): Promise { - const params: Record = {}; - if (args?.contractId) params.contractId = args.contractId; - if (args?.sourcePublicKey) params.sourcePublicKey = args.sourcePublicKey; - - return this.axios - .get("/contracts/version", { params }) - .then((r) => r.data); - } - - /** - * Migrate legacy vault data for an owner to the current format. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with migration details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ - vaultMigrate( + vcBatchIssue( payload: | { - /** Stellar account address (public key) that owns the vault */ owner: string; - - /** Optional contract ID (defaults to network contract) */ + issuer: string; + issuerDid?: string; + vcs: Array<{ vcId: string; vcData: string }>; + sourcePublicKey?: string; contractId?: string; - - /** Stellar public key that will sign the transaction */ - sourcePublicKey: string; } | { signedXdr: string } - ): Promise { + ): Promise { return this.axios - .post("/contracts/vault/migrate", payload) + .post("/contracts/vc/batch-issue", payload) .then((r) => r.data); } /** - * Replace the full authorized issuer list for an owner's vault. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with authorization details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` + * Revoke a credential. The contract requires `owner.require_auth()`, so + * `owner` is mandatory in prepare mode and the resulting XDR MUST be + * signed by the vault owner (relayer signatures do NOT satisfy the + * authorisation). */ - vaultAuthorizeIssuers( + revokeCredentialViaApi( payload: | { - /** Stellar account address (public key) that owns the vault */ owner: string; - - /** Array of issuer addresses (public keys) to authorize */ - issuers: string[]; - - /** Stellar public key that will sign the transaction */ - sourcePublicKey: string; - - /** Optional contract ID (defaults to network contract) */ + vcId: string; + date?: string; + sourcePublicKey?: string; contractId?: string; } | { signedXdr: string } - ): Promise { + ): Promise { return this.axios - .post( - "/contracts/vault/authorize-issuers", - payload - ) + .post("/contracts/vc/revoke", payload) .then((r) => r.data); } - /** - * Set a new vault owner (vault admin) for an existing vault. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with ownership details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ - vaultSetNewOwner( - payload: - | { - /** Current vault owner address (public key) */ - owner: string; - - /** New vault owner address (public key) */ - newOwner: string; - - /** Stellar public key that will sign the transaction (must be current vault admin) */ - sourcePublicKey: string; - - /** Optional contract ID (defaults to network contract) */ - contractId?: string; - } - | { signedXdr: string } - ): Promise { - if ("signedXdr" in payload) { - return this.axios - .post("/contracts/vault/set-new-owner", payload) - .then((r) => r.data); - } - - const body = { - owner: payload.owner, - new_owner: payload.newOwner, - contractId: payload.contractId, - sourcePublicKey: payload.sourcePublicKey, - }; + // ------------------------------------------------------------------------- + // Contract version + // ------------------------------------------------------------------------- + getContractVersion(args?: { + contractId?: string; + sourcePublicKey?: string; + }): Promise { + const params: Record = {}; + if (args?.contractId) params.contractId = args.contractId; + if (args?.sourcePublicKey) params.sourcePublicKey = args.sourcePublicKey; return this.axios - .post("/contracts/vault/set-new-owner", body) + .get("/contracts/version", { params }) .then((r) => r.data); } - /** - * Move a VC from one owner's vault to another. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with push details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ - vaultPush( - payload: - | { - /** Origin vault owner address (public key) */ - fromOwner: string; - - /** Destination vault owner address (public key) */ - toOwner: string; - - /** Credential identifier */ - vcId: string; - - /** Issuer address (public key) authorized in the origin vault */ - issuer: string; + // ------------------------------------------------------------------------- + // Sponsored vault + // ------------------------------------------------------------------------- - /** Stellar public key that will sign the transaction (must be fromOwner) */ - sourcePublicKey: string; - - /** Optional contract ID (defaults to network contract) */ - contractId?: string; - } - | { signedXdr: string } - ): Promise { - return this.axios - .post("/contracts/vault/push", payload) - .then((r) => r.data); - } - - /** - * Create a sponsored vault for an owner. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with sponsored vault details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ sponsoredVaultCreate( payload: | { - /** Sponsor address (public key) that pays for the vault creation */ sponsor: string; - - /** Vault owner address (public key) */ owner: string; - - /** DID URI of the vault owner */ didUri: string; - - /** Stellar public key that will sign the transaction (must be sponsor) */ sourcePublicKey: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -838,22 +605,11 @@ export class ActaClient { .then((r) => r.data); } - /** - * Set the sponsored vault open_to_all flag. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with flag details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ sponsoredVaultSetOpenToAll( payload: | { - /** Whether sponsored vaults are open to all (true) or restricted (false) */ open: boolean; - - /** Stellar public key that will sign the transaction */ sourcePublicKey: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -866,22 +622,13 @@ export class ActaClient { .then((r) => r.data); } - /** - * Read the sponsored vault open_to_all flag. - * @param args - Optional contract and source configuration - * @returns `{ open }` flag indicating if sponsored vaults are open to all. - */ getSponsoredVaultOpenToAll(args?: { - /** Optional contract ID override (defaults to network contract) */ contractId?: string; - - /** Optional source public key used for Soroban simulation */ sourcePublicKey?: string; }): Promise { const params: Record = {}; if (args?.contractId) params.contractId = args.contractId; if (args?.sourcePublicKey) params.sourcePublicKey = args.sourcePublicKey; - return this.axios .get( "/contracts/sponsored-vault/open-to-all", @@ -890,22 +637,11 @@ export class ActaClient { .then((r) => r.data); } - /** - * Add a sponsor address to the sponsored vault sponsors list. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with sponsor details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ sponsoredVaultAddSponsor( payload: | { - /** Sponsor address (public key) to add */ sponsor: string; - - /** Stellar public key that will sign the transaction */ sourcePublicKey: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -918,22 +654,11 @@ export class ActaClient { .then((r) => r.data); } - /** - * Remove a sponsor address from the sponsored vault sponsors list. - * Can prepare an unsigned XDR or submit a signed XDR. - * @param payload - Either prepare mode with sponsor details, or submit mode with signed XDR - * @returns Prepare mode: `{ xdr, network }` or Submit mode: `{ tx_id }` - */ sponsoredVaultRemoveSponsor( payload: | { - /** Sponsor address (public key) to remove */ sponsor: string; - - /** Stellar public key that will sign the transaction */ sourcePublicKey: string; - - /** Optional contract ID (defaults to network contract) */ contractId?: string; } | { signedXdr: string } @@ -945,4 +670,127 @@ export class ActaClient { ) .then((r) => r.data); } + + // ------------------------------------------------------------------------- + // Issuer registry (vc-issuer-registry, available API v1.2.0+) + // ------------------------------------------------------------------------- + + /** Register a new issuer (admin). */ + issuerRegistryAdd( + payload: + | { + issuer: string; + name?: string; + did?: string; + url?: string; + sourcePublicKey: string; + contractId?: string; + } + | { signedXdr: string } + ): Promise { + return this.axios + .post( + "/contracts/issuer-registry/issuer", + payload + ) + .then((r) => r.data); + } + + /** Overwrite metadata (admin). Preserves the `allowed` flag. */ + issuerRegistrySetMetadata( + issuer: string, + payload: + | { + name?: string; + did?: string; + url?: string; + sourcePublicKey: string; + contractId?: string; + } + | { signedXdr: string } + ): Promise { + return this.axios + .patch( + `/contracts/issuer-registry/issuer/${encodeURIComponent(issuer)}/metadata`, + payload + ) + .then((r) => r.data); + } + + /** Toggle the `allowed` flag (admin). */ + issuerRegistrySetAllowed( + issuer: string, + payload: + | { + allowed: boolean; + sourcePublicKey: string; + contractId?: string; + } + | { signedXdr: string } + ): Promise { + return this.axios + .patch( + `/contracts/issuer-registry/issuer/${encodeURIComponent(issuer)}/allowed`, + payload + ) + .then((r) => r.data); + } + + /** Hard-delete an issuer (admin). */ + issuerRegistryRemove( + issuer: string, + payload: + | { sourcePublicKey: string; contractId?: string } + | { signedXdr: string } + ): Promise { + return this.axios + .delete( + `/contracts/issuer-registry/issuer/${encodeURIComponent(issuer)}`, + { data: payload } + ) + .then((r) => r.data); + } + + /** Full record for an issuer. Rejects with `issuer_not_found` if missing. */ + issuerRegistryGet(args: { + issuer: string; + contractId?: string; + }): Promise { + const params: Record = {}; + if (args.contractId) params.contractId = args.contractId; + return this.axios + .get( + `/contracts/issuer-registry/issuer/${encodeURIComponent(args.issuer)}`, + { params } + ) + .then((r) => r.data); + } + + /** Cheap `{ allowed: boolean }` check. Unknown issuers return `false`. */ + issuerRegistryIsAllowed(args: { + issuer: string; + contractId?: string; + }): Promise { + const params: Record = {}; + if (args.contractId) params.contractId = args.contractId; + return this.axios + .get( + `/contracts/issuer-registry/issuer/${encodeURIComponent(args.issuer)}/allowed`, + { params } + ) + .then((r) => r.data); + } + + /** `{ admin, version }` — useful to sanity-check the deployed contract. */ + issuerRegistryStatus(args?: { + contractId?: string; + }): Promise { + const params: Record = {}; + if (args?.contractId) params.contractId = args.contractId; + return this.axios + .get("/contracts/issuer-registry/status", { + params, + }) + .then((r) => r.data); + } } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 34c02ab..3af07ad 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,9 +1,18 @@ /** * React hooks for the ACTA SDK. * - * Thin wrappers around the `ActaClient` for idiomatic usage in React apps. - * Organized by functionality based on actual API endpoints used. + * Thin wrappers around `ActaClient` for idiomatic usage in React apps. + * Organised by domain: + * + * - {@link useVault} — vault lifecycle (create, authorize, revoke). + * - {@link useVaultRead} — paginated reads + metadata + verify. + * - {@link useVaultIssuers} — issuer lists/counts per vault. + * - {@link useCredential} — issue / issueLinked / batchIssue / revoke. + * - {@link useIssuerRegistry} — global vc-issuer-registry allowlist + * (available once the contract is deployed). */ export * from "./useVault"; export * from "./useCredential"; export * from "./useVaultRead"; +export * from "./useVaultIssuers"; +export * from "./useIssuerRegistry"; diff --git a/src/hooks/useCredential.ts b/src/hooks/useCredential.ts index e48ca96..d43bd96 100644 --- a/src/hooks/useCredential.ts +++ b/src/hooks/useCredential.ts @@ -4,7 +4,9 @@ import { normalizeDid, ensureContextInVcData } from "../utils/credential-helpers /** Function that signs an unsigned XDR with the given network passphrase. */ type Signer = ( + // eslint-disable-next-line no-unused-vars unsignedXdr: string, + // eslint-disable-next-line no-unused-vars opts: { networkPassphrase: string } ) => Promise; @@ -12,143 +14,88 @@ type Signer = ( type VaultOwner = string; /** - * Hook for credential operations: issue and revoke. - * @returns Methods to manage credentials via the API. + * Hook for credential operations: issue, issueLinked, batchIssue, revoke. */ export function useCredential() { const client = useActaClient(); return { /** - * Issue a credential (stores in vault and marks as valid). - * @returns Transaction ID of the submitted transaction. + * Issue a single credential (stores in vault and marks as valid). */ issue: async (args: { - /** Wallet address of the vault owner. Can be G... (account) or C... (smart wallet). */ owner: VaultOwner; - - /** Credential ID */ vcId: string; - - /** Credential data (object or JSON string). @context is added automatically */ vcData: string | Record; - - /** Wallet address of the issuer */ issuer: string; - - /** Wallet address or DID of the issuer (DID is constructed automatically if wallet address) */ issuerDid?: string; - - /** Function to sign transactions */ signTransaction: Signer; - - /** Optional explicit source account (G...) that will sign the transaction. - * For G... owners, defaults to issuer when omitted. - * For C... owners, the backend uses the relayer regardless. */ sourcePublicKey?: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }) => { const cfg = await client.getConfig(); const contractId = args.contractId || cfg.actaContractId; - if (!contractId) throw new Error("Contract ID not configured"); const network = client.getNetwork(); - const issuerDid = args.issuerDid ? normalizeDid(args.issuerDid, network) : undefined; - - // Ensure @context is present in vcData const vcDataWithContext = ensureContextInVcData(args.vcData); const isSmartAccountOwner = args.owner.startsWith("C") && args.owner.length === 56; - // Prepare the transaction via API const prepareResult = await client.vcIssue({ owner: args.owner, vcId: args.vcId, vcData: vcDataWithContext, issuer: args.issuer, - issuerDid: issuerDid, + issuerDid, ...(isSmartAccountOwner ? {} - : { - sourcePublicKey: args.sourcePublicKey ?? args.issuer, - }), - contractId: contractId, + : { sourcePublicKey: args.sourcePublicKey ?? args.issuer }), + contractId, }); if (!isTxPrepareResponse(prepareResult)) { throw new Error("Failed to prepare issue credential transaction"); } - // Sign the transaction const signedXdr = await args.signTransaction(prepareResult.xdr, { networkPassphrase: prepareResult.network, }); - // Submit the signed transaction via API const submitResult = await client.vcIssue({ signedXdr }); - if (!isTxSubmitResponse(submitResult)) { throw new Error("Failed to submit issue credential transaction"); } - return { txId: submitResult.tx_id }; }, /** - * Issue a linked credential (stores in vault with parent VC reference). `POST /contracts/vc/issue-linked`. - * @returns Transaction ID of the submitted transaction. + * Issue a credential linked to a parent VC in another vault. */ issueLinked: async (args: { - /** Wallet address of the vault owner. Can be G... (account) or C... (smart wallet). */ owner: VaultOwner; - - /** Credential ID */ vcId: string; - - /** Credential data (object or JSON string). @context is added automatically */ vcData: string | Record; - - /** Wallet address of the issuer */ issuer: string; - - /** Wallet address or DID of the issuer (DID is constructed automatically if wallet address) */ issuerDid?: string; - - /** Function to sign transactions */ signTransaction: Signer; - - /** Optional explicit source account (G...) that will sign the transaction. - * For G... owners, defaults to issuer when omitted. - * For C... owners, the backend uses the relayer regardless. */ sourcePublicKey?: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; - - /** Wallet address of the parent VC owner */ parentOwner: string; - - /** Parent VC identifier */ parentVcId: string; }) => { const cfg = await client.getConfig(); const contractId = args.contractId || cfg.actaContractId; - if (!contractId) throw new Error("Contract ID not configured"); const network = client.getNetwork(); - const issuerDid = args.issuerDid ? normalizeDid(args.issuerDid, network) : undefined; - const vcDataWithContext = ensureContextInVcData(args.vcData); const isSmartAccountOwner = @@ -159,13 +106,11 @@ export function useCredential() { vcId: args.vcId, vcData: vcDataWithContext, issuer: args.issuer, - issuerDid: issuerDid, + issuerDid, ...(isSmartAccountOwner ? {} - : { - sourcePublicKey: args.sourcePublicKey ?? args.issuer, - }), - contractId: contractId, + : { sourcePublicKey: args.sourcePublicKey ?? args.issuer }), + contractId, parentOwner: args.parentOwner, parentVcId: args.parentVcId, }); @@ -179,75 +124,118 @@ export function useCredential() { }); const submitResult = await client.vcIssueLinked({ signedXdr }); - if (!isTxSubmitResponse(submitResult)) { throw new Error("Failed to submit issue linked credential transaction"); } + return { txId: submitResult.tx_id }; + }, + /** + * Issue up to 5 VCs in a single transaction. Maps to `POST + * /contracts/vc/batch-issue` (added in API v1.2.0). + */ + batchIssue: async (args: { + owner: VaultOwner; + issuer: string; + issuerDid?: string; + vcs: Array<{ vcId: string; vcData: string | Record }>; + signTransaction: Signer; + sourcePublicKey?: string; + contractId?: string; + }) => { + const cfg = await client.getConfig(); + const contractId = args.contractId || cfg.actaContractId; + if (!contractId) throw new Error("Contract ID not configured"); + if (!Array.isArray(args.vcs) || args.vcs.length === 0) { + throw new Error("vcs must contain at least one entry"); + } + if (args.vcs.length > 5) { + throw new Error("batchIssue accepts at most 5 vcs per call"); + } + + const network = client.getNetwork(); + const issuerDid = args.issuerDid + ? normalizeDid(args.issuerDid, network) + : undefined; + + const isSmartAccountOwner = + args.owner.startsWith("C") && args.owner.length === 56; + + const vcs = args.vcs.map((entry) => ({ + vcId: entry.vcId, + vcData: ensureContextInVcData(entry.vcData), + })); + + const prepareResult = await client.vcBatchIssue({ + owner: args.owner, + issuer: args.issuer, + issuerDid, + vcs, + ...(isSmartAccountOwner + ? {} + : { sourcePublicKey: args.sourcePublicKey ?? args.issuer }), + contractId, + }); + + if (!isTxPrepareResponse(prepareResult)) { + throw new Error("Failed to prepare batch issue transaction"); + } + + const signedXdr = await args.signTransaction(prepareResult.xdr, { + networkPassphrase: prepareResult.network, + }); + + const submitResult = await client.vcBatchIssue({ signedXdr }); + if (!isTxSubmitResponse(submitResult)) { + throw new Error("Failed to submit batch issue transaction"); + } return { txId: submitResult.tx_id }; }, /** - * Revoke a credential. - * @returns Transaction ID of the submitted transaction. + * Revoke a credential. The owner MUST sign — the contract calls + * `owner.require_auth()` and a relayer signature is not accepted. */ revoke: async (args: { - /** Wallet address of the vault owner. Can be G... (account) or C... (smart wallet). */ owner: VaultOwner; - - /** Credential ID to revoke */ vcId: string; - - /** Function to sign transactions */ signTransaction: Signer; - - /** Revocation date (ISO timestamp, optional, defaults to now) */ date?: string; - - /** Optional explicit source account (G...) that will sign the transaction. - * For G... owners, defaults to owner when omitted. - * For C... owners, the backend uses the relayer regardless. */ sourcePublicKey?: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }) => { const cfg = await client.getConfig(); const contractId = args.contractId || cfg.actaContractId; - if (!contractId) throw new Error("Contract ID not configured"); + if (args.owner.startsWith("C")) { + // The contract authorises the owner via require_auth() — for + // smart-account owners we'd need an account-contract signing flow + // which is not modelled here. + throw new Error( + "revoke() only supports G... vault owners (the contract calls owner.require_auth())" + ); + } - const isSmartAccountOwner = - args.owner.startsWith("C") && args.owner.length === 56; - - // Prepare the transaction via API const prepareResult = await client.revokeCredentialViaApi({ + owner: args.owner, vcId: args.vcId, date: args.date || new Date().toISOString(), - ...(isSmartAccountOwner - ? {} - : { - sourcePublicKey: args.sourcePublicKey ?? args.owner, - }), - contractId: contractId, + sourcePublicKey: args.sourcePublicKey ?? args.owner, + contractId, }); if (!isTxPrepareResponse(prepareResult)) { throw new Error("Failed to prepare revoke credential transaction"); } - // Sign the transaction const signedXdr = await args.signTransaction(prepareResult.xdr, { networkPassphrase: prepareResult.network, }); - // Submit the signed transaction via API const submitResult = await client.revokeCredentialViaApi({ signedXdr }); - if (!isTxSubmitResponse(submitResult)) { throw new Error("Failed to submit revoke credential transaction"); } - return { txId: submitResult.tx_id }; }, }; diff --git a/src/hooks/useIssuerRegistry.ts b/src/hooks/useIssuerRegistry.ts new file mode 100644 index 0000000..43bfc1a --- /dev/null +++ b/src/hooks/useIssuerRegistry.ts @@ -0,0 +1,158 @@ +import { useActaClient } from "../providers/ActaClientContext"; +import { isTxPrepareResponse, isTxSubmitResponse } from "../types/api-responses"; +import type { + IssuerRecord, + IssuerRegistryStatusResponse, + TxResponse, +} from "../types/api-responses"; + +/** Function that signs an unsigned XDR with the given network passphrase. */ +type Signer = ( + // eslint-disable-next-line no-unused-vars + unsignedXdr: string, + // eslint-disable-next-line no-unused-vars + opts: { networkPassphrase: string } +) => Promise; + +/** + * Hook for the global `vc-issuer-registry` allowlist (API v1.2.0+). + * + * - Reads (`get`, `isAllowed`, `status`) are public to any authenticated + * user. + * - Mutations (`add`, `setMetadata`, `setAllowed`, `remove`) require the + * registry admin to sign the prepared XDR. + * + * Until the contract is deployed, every method rejects with + * `contractId_invalid`. Set `ISSUER_REGISTRY_CONTRACT_ID` in the API + * environment when the contract is live. + */ +export function useIssuerRegistry() { + const client = useActaClient(); + + const runMutation = async ( + // eslint-disable-next-line no-unused-vars + prepare: () => Promise, + // eslint-disable-next-line no-unused-vars + submit: (signedXdr: string) => Promise, + sign: Signer + ) => { + const prep = await prepare(); + if (!isTxPrepareResponse(prep)) { + throw new Error("Failed to prepare issuer-registry transaction"); + } + const signed = await sign(prep.xdr, { networkPassphrase: prep.network }); + const submitResp = await submit(signed); + if (!isTxSubmitResponse(submitResp)) { + throw new Error("Failed to submit issuer-registry transaction"); + } + return { txId: submitResp.tx_id }; + }; + + return { + // --- Reads ------------------------------------------------------------- + + /** Full record for an issuer. Rejects with `issuer_not_found` if missing. */ + get: (args: { issuer: string; contractId?: string }): Promise => { + return client.issuerRegistryGet(args); + }, + + /** Cheap boolean check. Unknown issuers return `false`. */ + isAllowed: async (args: { + issuer: string; + contractId?: string; + }): Promise => { + const result = await client.issuerRegistryIsAllowed(args); + return result.allowed; + }, + + /** Registry admin and contract version. */ + status: (args?: { + contractId?: string; + }): Promise => { + return client.issuerRegistryStatus(args); + }, + + // --- Mutations (admin signs) ------------------------------------------- + + add: (args: { + issuer: string; + name?: string; + did?: string; + url?: string; + sourcePublicKey: string; + signTransaction: Signer; + contractId?: string; + }) => + runMutation( + () => + client.issuerRegistryAdd({ + issuer: args.issuer, + name: args.name, + did: args.did, + url: args.url, + sourcePublicKey: args.sourcePublicKey, + contractId: args.contractId, + }), + (signedXdr) => client.issuerRegistryAdd({ signedXdr }), + args.signTransaction + ), + + setMetadata: (args: { + issuer: string; + name?: string; + did?: string; + url?: string; + sourcePublicKey: string; + signTransaction: Signer; + contractId?: string; + }) => + runMutation( + () => + client.issuerRegistrySetMetadata(args.issuer, { + name: args.name, + did: args.did, + url: args.url, + sourcePublicKey: args.sourcePublicKey, + contractId: args.contractId, + }), + (signedXdr) => + client.issuerRegistrySetMetadata(args.issuer, { signedXdr }), + args.signTransaction + ), + + setAllowed: (args: { + issuer: string; + allowed: boolean; + sourcePublicKey: string; + signTransaction: Signer; + contractId?: string; + }) => + runMutation( + () => + client.issuerRegistrySetAllowed(args.issuer, { + allowed: args.allowed, + sourcePublicKey: args.sourcePublicKey, + contractId: args.contractId, + }), + (signedXdr) => + client.issuerRegistrySetAllowed(args.issuer, { signedXdr }), + args.signTransaction + ), + + remove: (args: { + issuer: string; + sourcePublicKey: string; + signTransaction: Signer; + contractId?: string; + }) => + runMutation( + () => + client.issuerRegistryRemove(args.issuer, { + sourcePublicKey: args.sourcePublicKey, + contractId: args.contractId, + }), + (signedXdr) => client.issuerRegistryRemove(args.issuer, { signedXdr }), + args.signTransaction + ), + }; +} diff --git a/src/hooks/useVault.ts b/src/hooks/useVault.ts index 3bb7d8f..6f0e0f0 100644 --- a/src/hooks/useVault.ts +++ b/src/hooks/useVault.ts @@ -1,203 +1,175 @@ import { useActaClient } from "../providers/ActaClientContext"; import { isTxPrepareResponse, isTxSubmitResponse } from "../types/api-responses"; +import { CONTRACT_LIMITS } from "../utils/contract-limits"; /** Function that signs an unsigned XDR with the given network passphrase. */ type Signer = ( + // eslint-disable-next-line no-unused-vars unsignedXdr: string, + // eslint-disable-next-line no-unused-vars opts: { networkPassphrase: string } ) => Promise; -/** Vault owner: can be a Stellar account (G...) or a smart contract wallet (C...). */ +/** Vault owner: G... (account) or C... (smart wallet). */ type VaultOwner = string; /** - * Hook for vault operations: create, authorize issuer, revoke issuer. - * @returns Methods to manage vault operations via the API. + * Hook for vault lifecycle: create, authorize (single + bulk), revoke + * issuer, revoke vault. */ export function useVault() { const client = useActaClient(); return { - /** - * Create (initialize) a vault for an owner. - * @returns Transaction ID of the submitted transaction. - */ + /** Create (initialize) a vault for an owner. */ createVault: async (args: { - /** Wallet address of the vault owner. Can be G... (account) or C... (smart wallet). */ owner: VaultOwner; - - /** DID of the vault owner */ ownerDid: string; - - /** Function to sign transactions */ signTransaction: Signer; - - /** Optional explicit source account (G...) that will sign the transaction. - * For G... owners, defaults to owner when omitted. - * For C... owners, the backend uses the relayer regardless. */ sourcePublicKey?: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }) => { const cfg = await client.getConfig(); const contractId = args.contractId || cfg.actaContractId; - if (!contractId) throw new Error("Contract ID not configured"); const isSmartAccountOwner = args.owner.startsWith("C") && args.owner.length === 56; - // Prepare the transaction via API const prepareResult = await client.vaultCreate({ owner: args.owner, didUri: args.ownerDid, ...(isSmartAccountOwner ? {} - : { - sourcePublicKey: args.sourcePublicKey ?? args.owner, - }), - contractId: contractId, + : { sourcePublicKey: args.sourcePublicKey ?? args.owner }), + contractId, }); - if (!isTxPrepareResponse(prepareResult)) { throw new Error("Failed to prepare vault creation transaction"); } - // Sign the transaction const signedXdr = await args.signTransaction(prepareResult.xdr, { networkPassphrase: prepareResult.network, }); - - // Submit the signed transaction via API const submitResult = await client.vaultCreate({ signedXdr }); - if (!isTxSubmitResponse(submitResult)) { throw new Error("Failed to submit vault creation transaction"); } - return { txId: submitResult.tx_id }; }, - /** - * Authorize an issuer in a vault. - * @returns Transaction ID of the submitted transaction. - */ + /** Authorize a single issuer in the vault. */ authorizeIssuer: async (args: { - /** Wallet address of the vault owner. Can be G... (account) or C... (smart wallet). */ owner: VaultOwner; - - /** Wallet address of the issuer to authorize */ issuer: string; - - /** Function to sign transactions */ signTransaction: Signer; - - /** Optional explicit source account (G...) that will sign the transaction. - * For G... owners, defaults to owner when omitted. - * For C... owners, the backend uses the relayer regardless. */ sourcePublicKey?: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }) => { const cfg = await client.getConfig(); const contractId = args.contractId || cfg.actaContractId; - if (!contractId) throw new Error("Contract ID not configured"); const isSmartAccountOwner = args.owner.startsWith("C") && args.owner.length === 56; - // Prepare the transaction via API const prepareResult = await client.vaultAuthorizeIssuer({ owner: args.owner, issuer: args.issuer, ...(isSmartAccountOwner ? {} - : { - sourcePublicKey: args.sourcePublicKey ?? args.owner, - }), - contractId: contractId, + : { sourcePublicKey: args.sourcePublicKey ?? args.owner }), + contractId, }); - if (!isTxPrepareResponse(prepareResult)) { throw new Error("Failed to prepare authorize issuer transaction"); } - // Sign the transaction const signedXdr = await args.signTransaction(prepareResult.xdr, { networkPassphrase: prepareResult.network, }); - - // Submit the signed transaction via API const submitResult = await client.vaultAuthorizeIssuer({ signedXdr }); - if (!isTxSubmitResponse(submitResult)) { throw new Error("Failed to submit authorize issuer transaction"); } - return { txId: submitResult.tx_id }; }, /** - * Revoke (remove) an authorized issuer from a vault. - * @returns Transaction ID of the submitted transaction. + * Replace the full authorised-issuer list in one transaction. Capped at + * `MAX_ISSUERS_LIST = 100` by the contract. */ - revokeIssuer: async (args: { - /** Wallet address of the vault owner. Can be G... (account) or C... (smart wallet). */ + authorizeIssuers: async (args: { owner: VaultOwner; + issuers: string[]; + signTransaction: Signer; + sourcePublicKey?: string; + contractId?: string; + }) => { + const cfg = await client.getConfig(); + const contractId = args.contractId || cfg.actaContractId; + if (!contractId) throw new Error("Contract ID not configured"); + if (args.issuers.length > CONTRACT_LIMITS.MAX_ISSUERS_LIST) { + throw new Error( + `authorizeIssuers accepts at most ${CONTRACT_LIMITS.MAX_ISSUERS_LIST} entries per call` + ); + } - /** Wallet address of the issuer to revoke */ - issuer: string; + const sourcePublicKey = args.sourcePublicKey ?? args.owner; + const prepareResult = await client.vaultAuthorizeIssuers({ + owner: args.owner, + issuers: args.issuers, + sourcePublicKey, + contractId, + }); + if (!isTxPrepareResponse(prepareResult)) { + throw new Error("Failed to prepare authorize issuers (bulk) transaction"); + } - /** Function to sign transactions */ - signTransaction: Signer; + const signedXdr = await args.signTransaction(prepareResult.xdr, { + networkPassphrase: prepareResult.network, + }); + const submitResult = await client.vaultAuthorizeIssuers({ signedXdr }); + if (!isTxSubmitResponse(submitResult)) { + throw new Error("Failed to submit authorize issuers (bulk) transaction"); + } + return { txId: submitResult.tx_id }; + }, - /** Optional explicit source account (G...) that will sign the transaction. - * For G... owners, defaults to owner when omitted. - * For C... owners, the backend uses the relayer regardless. */ + /** Remove an issuer from the authorised list. */ + revokeIssuer: async (args: { + owner: VaultOwner; + issuer: string; + signTransaction: Signer; sourcePublicKey?: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }) => { const cfg = await client.getConfig(); const contractId = args.contractId || cfg.actaContractId; - if (!contractId) throw new Error("Contract ID not configured"); const isSmartAccountOwner = args.owner.startsWith("C") && args.owner.length === 56; - // Prepare the transaction via API const prepareResult = await client.vaultRevokeIssuerViaApi({ owner: args.owner, issuer: args.issuer, ...(isSmartAccountOwner ? {} - : { - sourcePublicKey: args.sourcePublicKey ?? args.owner, - }), - contractId: contractId, + : { sourcePublicKey: args.sourcePublicKey ?? args.owner }), + contractId, }); - if (!isTxPrepareResponse(prepareResult)) { throw new Error("Failed to prepare revoke issuer transaction"); } - // Sign the transaction const signedXdr = await args.signTransaction(prepareResult.xdr, { networkPassphrase: prepareResult.network, }); - - // Submit the signed transaction via API const submitResult = await client.vaultRevokeIssuerViaApi({ signedXdr }); - if (!isTxSubmitResponse(submitResult)) { throw new Error("Failed to submit revoke issuer transaction"); } - return { txId: submitResult.tx_id }; }, }; diff --git a/src/hooks/useVaultIssuers.ts b/src/hooks/useVaultIssuers.ts new file mode 100644 index 0000000..12bfffd --- /dev/null +++ b/src/hooks/useVaultIssuers.ts @@ -0,0 +1,116 @@ +import { useActaClient } from "../providers/ActaClientContext"; +import { CONTRACT_LIMITS } from "../utils/contract-limits"; + +/** + * Hook for reading the authorised / denied issuer lists of a vault. + * + * `list` and `listDenied` are paginated (default `limit = 50`, capped at + * `MAX_LIST_LIMIT = 200`). `listAll` and `listAllDenied` auto-paginate + * using the O(1) count endpoints; they default-cap at 10 000 entries to + * protect callers from runaway loops. + */ +export function useVaultIssuers() { + const client = useActaClient(); + + return { + /** O(1) count of authorised issuers. */ + count: async (args: { + owner: string; + contractId?: string; + }): Promise => { + const result = await client.vaultAuthorizedIssuerCount(args); + return result.count; + }, + + /** O(1) count of denied (revoked) issuers. */ + countDenied: async (args: { + owner: string; + contractId?: string; + }): Promise => { + const result = await client.vaultDeniedIssuerCount(args); + return result.count; + }, + + /** Single page of authorised issuers. */ + list: async (args: { + owner: string; + offset?: number; + limit?: number; + contractId?: string; + }): Promise => { + const result = await client.vaultListAuthorizedIssuers(args); + return result.issuers; + }, + + /** Single page of denied issuers. */ + listDenied: async (args: { + owner: string; + offset?: number; + limit?: number; + contractId?: string; + }): Promise => { + const result = await client.vaultListDeniedIssuers(args); + return result.issuers; + }, + + /** Full authorised-issuer list, auto-paginated. */ + listAll: async (args: { + owner: string; + pageSize?: number; + maxItems?: number; + contractId?: string; + }): Promise => { + const pageSize = Math.min( + args.pageSize ?? CONTRACT_LIMITS.MAX_LIST_LIMIT, + CONTRACT_LIMITS.MAX_LIST_LIMIT + ); + const maxItems = args.maxItems ?? 10_000; + const total = Math.min( + (await client.vaultAuthorizedIssuerCount(args)).count, + maxItems + ); + const out: string[] = []; + for (let offset = 0; offset < total; offset += pageSize) { + const page = await client.vaultListAuthorizedIssuers({ + owner: args.owner, + offset, + limit: Math.min(pageSize, total - offset), + contractId: args.contractId, + }); + out.push(...page.issuers); + if (page.issuers.length === 0) break; + } + return out; + }, + + /** Full denied-issuer list, auto-paginated. */ + listAllDenied: async (args: { + owner: string; + pageSize?: number; + maxItems?: number; + contractId?: string; + }): Promise => { + const pageSize = Math.min( + args.pageSize ?? CONTRACT_LIMITS.MAX_LIST_LIMIT, + CONTRACT_LIMITS.MAX_LIST_LIMIT + ); + const maxItems = args.maxItems ?? 10_000; + const total = Math.min( + (await client.vaultDeniedIssuerCount(args)).count, + maxItems + ); + const out: string[] = []; + for (let offset = 0; offset < total; offset += pageSize) { + const page = await client.vaultListDeniedIssuers({ + owner: args.owner, + offset, + limit: Math.min(pageSize, total - offset), + contractId: args.contractId, + }); + out.push(...page.issuers); + if (page.issuers.length === 0) break; + } + return out; + }, + }; +} diff --git a/src/hooks/useVaultRead.ts b/src/hooks/useVaultRead.ts index 3914e80..ff75b11 100644 --- a/src/hooks/useVaultRead.ts +++ b/src/hooks/useVaultRead.ts @@ -1,92 +1,119 @@ import { useActaClient } from "../providers/ActaClientContext"; -import type { VaultGetVcParentResponse, VaultVerifyVcResponse } from "../types/api-responses"; +import type { + VaultGetVcParentResponse, + VaultVerifyVcResponse, + VaultMetadataResponse, +} from "../types/api-responses"; +import { CONTRACT_LIMITS } from "../utils/contract-limits"; /** - * Hook for reading vault data: list VC IDs, get VC, verify VC. - * @returns Methods to read vault data via the API. + * Hook for reading vault data. Updated for API v1.2.0: + * + * - `listVcIds` now accepts `offset` / `limit` (default 0 / 50, max 200). + * - `listAllVcIds` automatically paginates using `vcCount`. + * - New `vcCount`, `metadata`, and `getVcParent` helpers. + * - `verifyVc` return type widened to include `'invalid'` / `'unknown'`. */ export function useVaultRead() { const client = useActaClient(); return { /** - * List VC IDs owned by an owner. - * @returns Array of VC IDs. + * List a single page of VC IDs. Defaults to the first 50 IDs. */ listVcIds: async (args: { - /** Wallet address of the vault owner */ owner: string; + offset?: number; + limit?: number; + contractId?: string; + }): Promise => { + const result = await client.vaultListVcIdsDirect(args); + return Array.isArray(result.result) + ? result.result + : Array.isArray(result.vc_ids) + ? result.vc_ids + : []; + }, - /** Contract ID (optional, defaults to network contract) */ + /** + * Fetch every VC ID in the vault. Uses {@link vcCount} to size the + * iteration so we never paginate past the end. `pageSize` defaults to + * {@link CONTRACT_LIMITS.MAX_LIST_LIMIT}. + * + * Defensive cap: refuses to fetch more than 10 000 IDs to avoid OOMing + * the caller on a runaway vault. Raise it explicitly via `maxItems` + * when you need the full list. + */ + listAllVcIds: async (args: { + owner: string; + pageSize?: number; + maxItems?: number; contractId?: string; }): Promise => { - const result = await client.vaultListVcIdsDirect({ + const pageSize = Math.min( + args.pageSize ?? CONTRACT_LIMITS.MAX_LIST_LIMIT, + CONTRACT_LIMITS.MAX_LIST_LIMIT + ); + const maxItems = args.maxItems ?? 10_000; + + const countResp = await client.vaultVcCount({ owner: args.owner, contractId: args.contractId, }); - return Array.isArray(result.vc_ids) - ? result.vc_ids - : Array.isArray(result.result) - ? result.result - : []; + const total = Math.min(countResp.count, maxItems); + + const out: string[] = []; + for (let offset = 0; offset < total; offset += pageSize) { + const page = await client.vaultListVcIdsDirect({ + owner: args.owner, + offset, + limit: Math.min(pageSize, total - offset), + contractId: args.contractId, + }); + const items = Array.isArray(page.result) + ? page.result + : Array.isArray(page.vc_ids) + ? page.vc_ids + : []; + out.push(...items); + if (items.length === 0) break; // defensive: stop if the API short-paginates + } + return out; }, - /** - * Get a credential from the vault. - * @returns VC data or null if not found. - */ - getVc: async (args: { - /** Wallet address of the vault owner */ + /** O(1) active-VC count. */ + vcCount: async (args: { owner: string; + contractId?: string; + }): Promise => { + const result = await client.vaultVcCount(args); + return result.count; + }, - /** Credential ID */ + /** Fetch a credential payload. */ + getVc: async (args: { + owner: string; vcId: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }): Promise => { - const result = await client.vaultGetVcDirect({ - owner: args.owner, - vcId: args.vcId, - contractId: args.contractId, - }); + const result = await client.vaultGetVcDirect(args); return result.vc ?? result.result ?? null; }, - /** - * Get the parent VC info for a linked credential. - * @returns Parent VC info or null if no parent link. - */ + /** Parent of a linked VC (`null` if not linked). */ getVcParent: async (args: { - /** Wallet address of the vault owner */ owner: string; - - /** Credential ID */ vcId: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }): Promise => { - const result = await client.vaultGetVcParent({ - owner: args.owner, - vcId: args.vcId, - contractId: args.contractId, - }); + const result = await client.vaultGetVcParent(args); return result.parent; }, - /** - * Verify a credential status in the vault. - * @returns Verification result with status and optional since date. - */ + /** Verify a credential status. */ verifyVc: async (args: { - /** Wallet address of the vault owner */ owner: string; - - /** Credential ID */ vcId: string; - - /** Contract ID (optional, defaults to network contract) */ contractId?: string; }): Promise => { return client.vaultVerify({ @@ -95,5 +122,16 @@ export function useVaultRead() { vaultContractId: args.contractId, }); }, + + /** + * Combined vault metadata (`admin`, `did_uri`, `revoked`, `vc_count`, + * `authorized_issuer_count`) in one round-trip. + */ + metadata: async (args: { + owner: string; + contractId?: string; + }): Promise => { + return client.vaultMetadata(args); + }, }; } diff --git a/src/index.ts b/src/index.ts index 2412378..df8870a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,27 @@ /** - * Entry point for the ACTA SDK. + * Entry point for the ACTA SDK (`@acta-team/credentials`). * - * Exposes the provider, client hooks, environment base URLs, - * and re-exports hooks and types. + * Exposes the React provider/context, the raw `ActaClient`, the typed + * `ActaError`, the per-contract `CONTRACT_LIMITS`, and re-exports the + * hooks and shared types. */ export { ActaConfig } from "./providers/ActaProvider"; export { useActaClient } from "./providers/ActaClientContext"; +export { ActaClient } from "./client"; /** Base API URL for ACTA mainnet. */ export const mainNet = "https://acta.build/api/mainnet"; /** Base API URL for ACTA testnet. */ export const testNet = "https://acta.build/api/testnet"; +/** Typed error thrown by every `ActaClient` method on non-2xx. */ +export { ActaError, actaErrorFromAxios } from "./utils/acta-error"; +export type { ActaErrorCode } from "./utils/acta-error"; + +/** Mirrors the contract-side caps so UIs can validate inputs locally. */ +export { CONTRACT_LIMITS } from "./utils/contract-limits"; +export type { ContractLimits } from "./utils/contract-limits"; + /** Re-export all hooks. */ export * from "./hooks"; /** Re-export all type definitions. */ diff --git a/src/types/api-responses.ts b/src/types/api-responses.ts index 4134ec4..1dfa62c 100644 --- a/src/types/api-responses.ts +++ b/src/types/api-responses.ts @@ -1,10 +1,22 @@ /** * API Response Types - * Type definitions for all ACTA API endpoint responses + * Type definitions for all ACTA API endpoint responses. + * + * Updated for API v1.2.0 / vc-vault v0.3.0: + * - `VaultVerifyVcResponse.status` now includes `"invalid"` and `"unknown"`. + * - `VaultListVcIdsResponse` reports the resolved `offset` and `limit`. + * - New shapes for `batch-issue`, `vc-count`, issuer lists/counts, vault + * metadata, and the `vc-issuer-registry` endpoints. + * - `VaultMigrateResponse` removed (the `/contracts/vault/migrate` endpoint + * was removed alongside the contract function). */ +// --------------------------------------------------------------------------- +// Shared building blocks +// --------------------------------------------------------------------------- + /** - * Configuration response from /config endpoint + * Configuration response from `/config`. */ export interface ConfigResponse { rpcUrl: string; @@ -13,7 +25,7 @@ export interface ConfigResponse { } /** - * Health check response from /health endpoint + * Health check response from `/health`. */ export interface HealthResponse { status: string; @@ -24,8 +36,8 @@ export interface HealthResponse { } /** - * Transaction preparation response (prepare mode) - * Returns unsigned XDR and network passphrase + * Transaction preparation response (prepare mode). Returns the unsigned XDR + * and the network passphrase the caller must sign for. */ export interface TxPrepareResponse { xdr: string; @@ -33,199 +45,184 @@ export interface TxPrepareResponse { } /** - * Transaction submission response (submit mode) - * Returns transaction ID + * Transaction submission response (submit mode). */ export interface TxSubmitResponse { tx_id: string; } /** - * Combined response for endpoints that support both prepare and submit - * Type guard helpers are available to distinguish between prepare and submit responses + * Combined response for endpoints that support both prepare and submit. + * Use {@link isTxPrepareResponse} / {@link isTxSubmitResponse} to narrow. */ export type TxResponse = TxPrepareResponse | TxSubmitResponse; -/** - * Type guard to check if response is a prepare response - */ export function isTxPrepareResponse( response: TxResponse ): response is TxPrepareResponse { return "xdr" in response && "network" in response; } -/** - * Type guard to check if response is a submit response - */ export function isTxSubmitResponse( response: TxResponse ): response is TxSubmitResponse { return "tx_id" in response; } -/** - * Vault create response - */ -export type VaultCreateResponse = TxResponse; +// --------------------------------------------------------------------------- +// Vault — mutation responses (all prepare/submit) +// --------------------------------------------------------------------------- -/** - * Vault authorize issuer response - */ +export type VaultCreateResponse = TxResponse; export type VaultAuthorizeIssuerResponse = TxResponse; - -/** - * Vault revoke issuer response - */ +export type VaultAuthorizeIssuersResponse = TxResponse; export type VaultRevokeIssuerResponse = TxResponse; +export type VaultRevokeVaultResponse = TxResponse; +export type VaultSetNewOwnerResponse = TxResponse; +export type VaultPushResponse = TxResponse; -/** - * VC issue response - */ -export type VcIssueResponse = TxResponse; +// --------------------------------------------------------------------------- +// VC — mutation responses +// --------------------------------------------------------------------------- -/** - * VC issue-linked response (same prepare/submit shape as `vc/issue`). - * Returned by `POST /contracts/vc/issue-linked`. - */ +export type VcIssueResponse = TxResponse; export type VcIssueLinkedResponse = TxResponse; - -/** - * Parent link for a VC returned by `POST /contracts/vault/get-vc-parent`. - * Matches API JSON (`vc_id` from the contract). - */ -export interface VaultVcParentInfo { - owner: string; - vc_id: string; -} - -/** - * Response from `POST /contracts/vault/get-vc-parent`. - */ -export interface VaultGetVcParentResponse { - parent: VaultVcParentInfo | null; -} - -/** - * VC revoke response - */ +export type VcBatchIssueResponse = TxResponse; export type VcRevokeResponse = TxResponse; -/** - * Vault revoke vault response - */ -export type VaultRevokeVaultResponse = TxResponse; +// --------------------------------------------------------------------------- +// Vault — read responses +// --------------------------------------------------------------------------- /** - * Vault list VC IDs response. - * Returned by `/contracts/vault/list-vc-ids`. + * Vault list VC IDs response (paginated as of API v1.2.0). + * Returned by `POST /contracts/vault/list-vc-ids`. */ export interface VaultListVcIdsResponse { /** - * List of credential identifiers returned by the unified contracts API. - * This is the preferred field for new integrations. + * List of credential identifiers. The API returns this under `result`. + * The optional `vc_ids` alias is preserved for forward-compat with any + * future shape change but is currently always `undefined`. */ vc_ids?: string[]; - - /** - * Optional legacy-style field that may contain the same credential IDs. - * Some lower-level contract helpers can still return this shape. - */ result?: string[]; + /** Resolved `offset` (after defaults). Echoed by the API for clarity. */ + offset?: number; + /** Resolved `limit` (after defaults). */ + limit?: number; } -/** - * Vault get VC response. - * Returned by `/contracts/vault/get-vc`. - */ export interface VaultGetVcResponse { - /** - * Verifiable Credential object returned by the ACTA contract. - * This is the high-level, normalized representation. - */ vc?: unknown; - - /** - * Optional raw contract result (low-level Soroban data or legacy format). - * Prefer using `vc` when available. - */ result?: unknown; } /** - * Vault verify VC response. - * Returned by `/contracts/vault/verify-vc`. + * Parent link for a VC returned by `POST /contracts/vault/get-vc-parent`. */ -export interface VaultVerifyVcResponse { - /** - * Verification status of the credential: - * - `"valid"`: the credential is currently valid in the vault/issuance contract. - * - `"revoked"`: the credential has been revoked. - */ - status: "valid" | "revoked"; +export interface VaultVcParentInfo { + owner: string; + vc_id: string; +} - /** - * Optional ISO timestamp for when the VC entered the current `status` - * (for example, revocation time when status is `"revoked"`). - */ - since?: string; +export interface VaultGetVcParentResponse { + parent: VaultVcParentInfo | null; } /** - * Contract version response from /contracts/version endpoint + * Vault verify VC response. Returned by `POST /contracts/vault/verify-vc`. + * + * - `"valid"` — credential is present and not revoked. + * - `"revoked"` — `since` carries the ISO-8601 timestamp the owner passed to `revoke`. + * - `"invalid"` — credential does not exist in the vault (returned by the + * contract's `VCStatus::Invalid`). + * - `"unknown"` — defensive fallback when the API returns a value the SDK + * does not recognise. */ -export interface ContractVersionResponse { - version: string; +export interface VaultVerifyVcResponse { + status: "valid" | "revoked" | "invalid" | "unknown"; + since?: string; } -/** - * Vault migrate response - */ -export type VaultMigrateResponse = TxResponse; +/** O(1) active-VC count. */ +export interface VaultVcCountResponse { + count: number; +} -/** - * Vault push response - */ -export type VaultPushResponse = TxResponse; +/** Paginated list of issuer addresses. */ +export interface VaultIssuerListResponse { + issuers: string[]; + offset: number; + limit: number; +} -/** - * Vault set new owner response - */ -export type VaultSetNewOwnerResponse = TxResponse; +/** O(1) issuer count (authorized or denied bucket). */ +export interface VaultIssuerCountResponse { + count: number; +} /** - * Vault authorize issuers (bulk) response + * Combined vault metadata returned by `GET /contracts/vault/:owner`. + * + * `admin`, `did_uri` and `revoked` come from the contract's per-vault + * persistent ledger entries (read directly via `getLedgerEntries`). + * Uninitialised vaults return `null` / `null` / `false`. */ -export type VaultAuthorizeIssuersResponse = TxResponse; +export interface VaultMetadataResponse { + owner: string; + admin: string | null; + did_uri: string | null; + revoked: boolean; + vc_count: number; + authorized_issuer_count: number; +} -/** - * Sponsored vault create response - */ -export type SponsoredVaultCreateResponse = TxResponse; +// --------------------------------------------------------------------------- +// Contract version +// --------------------------------------------------------------------------- -/** - * Sponsored vault set open-to-all flag response - */ -export type SponsoredVaultSetOpenToAllResponse = TxResponse; +export interface ContractVersionResponse { + version: string; +} -/** - * Sponsored vault add sponsor response - */ -export type SponsoredVaultAddSponsorResponse = TxResponse; +// --------------------------------------------------------------------------- +// Sponsored vault +// --------------------------------------------------------------------------- -/** - * Sponsored vault remove sponsor response - */ +export type SponsoredVaultCreateResponse = TxResponse; +export type SponsoredVaultSetOpenToAllResponse = TxResponse; +export type SponsoredVaultAddSponsorResponse = TxResponse; export type SponsoredVaultRemoveSponsorResponse = TxResponse; -/** - * Sponsored vault `open_to_all` read response. - * Returned by `GET /contracts/sponsored-vault/open-to-all`. - */ export interface SponsoredVaultOpenToAllReadResponse { /** - * `true` si los sponsored vaults pueden crearse por cualquier caller. - * `false` si solo los sponsors permitidos pueden crear sponsored vaults. + * `true` if anyone can create sponsored vaults; `false` if only the + * configured sponsor allowlist may. */ open: boolean; } + +// --------------------------------------------------------------------------- +// Issuer registry (vc-issuer-registry, API v1.2.0) +// --------------------------------------------------------------------------- + +export type IssuerRegistryAddResponse = TxResponse; +export type IssuerRegistrySetMetadataResponse = TxResponse; +export type IssuerRegistrySetAllowedResponse = TxResponse; +export type IssuerRegistryRemoveResponse = TxResponse; + +export interface IssuerRecord { + allowed: boolean; + name: string | null; + did: string | null; + url: string | null; +} + +export interface IssuerRegistryIsAllowedResponse { + allowed: boolean; +} + +export interface IssuerRegistryStatusResponse { + admin: string; + version: string; +} diff --git a/src/types/index.ts b/src/types/index.ts index 9bd9f3f..13be279 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,3 @@ /** Re-export of all type definitions used by the ACTA SDK. */ -export * from "./type.payload"; -export * from "./types.response"; export * from "./types"; export * from "./api-responses"; diff --git a/src/types/type.payload.ts b/src/types/type.payload.ts deleted file mode 100644 index e501cdb..0000000 --- a/src/types/type.payload.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Payload variants to create a credential. - * - Signed XDR flow: submit `signedXdr` and `vcId`. - * - Server-issued flow: provide `owner`, `vcId`, `vcData`, and `vaultContractId` (optional `didUri`). - */ -export type CreateCredentialPayload = - | { - signedXdr: string; - vcId: string; - } - | { - owner: string; - vcId: string; - vcData: string; - vaultContractId: string; - didUri?: string; - }; diff --git a/src/types/types.response.ts b/src/types/types.response.ts deleted file mode 100644 index 7e4f353..0000000 --- a/src/types/types.response.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Response body for `/credentials` issuance call. */ -export type CreateCredentialResponse = { - /** Credential identifier. */ - vc_id: string; - /** Transaction ID of the issuance or store action. */ - tx_id: string; -}; diff --git a/src/utils/acta-error.ts b/src/utils/acta-error.ts new file mode 100644 index 0000000..ee184ce --- /dev/null +++ b/src/utils/acta-error.ts @@ -0,0 +1,139 @@ +/** + * Typed error for failures coming back from the ACTA API. + * + * The API returns a stable JSON shape on errors: + * + * { + * "error": "vc_already_exists", // machine-readable code + * "message": "Error(Contract, #12)", + * "request_id": "...", + * "retry_after": 30 + * } + * + * `ActaError` extracts that into a JS error so consumers can branch on + * `err.code` instead of substring-matching the message. The axios + * interceptor installed by {@link ActaClient} converts every non-2xx + * response into one of these. + * + * The `code` field is left as a free-form string (typed loosely via + * {@link ActaErrorCode}) so future contracts / new error codes don't break + * the type. Match the catalogue in + * `acta-api/docs/integrators/error-codes.md` when adding handling. + */ + +/** Known error codes shipped by the API as of v1.2.0. Strings are kept open + * so unknown future codes still match `ActaErrorCode`. */ +export type ActaErrorCode = + // Generic + | "bad_request" + | "unauthorized" + | "forbidden" + | "not_found" + | "internal_error" + | "rate_limit_exceeded" + | "write_rate_limit_exceeded" + | "rate_limit_unavailable" + // vc-vault + | "vault_already_exists" + | "issuer_not_authorized" + | "issuer_already_authorized" + | "vault_revoked" + | "vc_not_found" + | "vc_already_revoked" + | "vault_not_initialized" + | "contract_not_initialized" + | "invalid_vault_contract" + | "not_authorized_sponsor" + | "vc_already_exists" + | "no_pending_admin" + | "parent_vc_invalid" + | "vault_full" + | "limit_too_large" + | "batch_too_large" + | "batch_empty" + | "input_too_long" + | "issuer_list_too_long" + | "invalid_fee_amount" + | "fee_out_of_bounds" + // vc-issuer-registry + | "issuer_registry_already_initialized" + | "issuer_not_found" + | "issuer_already_exists" + | "issuer_registry_not_initialized" + | "invalid_issuer_metadata" + // Fallback for forward-compat + | (string & {}); + +/** + * Error thrown by every `ActaClient` method when the API returns a non-2xx. + */ +export class ActaError extends Error { + /** Stable, machine-readable code (e.g. `vc_already_exists`). */ + readonly code: ActaErrorCode; + /** Original HTTP status code returned by the API. */ + readonly httpStatus: number; + /** Per-request correlation ID; useful when filing support tickets. */ + readonly requestId?: string; + /** Server-suggested wait in seconds (for `429` responses). */ + readonly retryAfter?: number; + /** Optional details map returned by the server (validation errors, etc.). */ + readonly details?: Record; + + constructor(opts: { + code: ActaErrorCode; + httpStatus: number; + message?: string; + requestId?: string; + retryAfter?: number; + details?: Record; + }) { + super(opts.message || opts.code); + this.name = "ActaError"; + this.code = opts.code; + this.httpStatus = opts.httpStatus; + this.requestId = opts.requestId; + this.retryAfter = opts.retryAfter; + this.details = opts.details; + } +} + +/** + * Best-effort conversion of an axios error into an {@link ActaError}. + * Falls back to a generic `internal_error` / `network_error` when the + * response shape does not match the ACTA contract. + */ +export function actaErrorFromAxios(err: unknown): ActaError { + const anyErr = err as { + response?: { + status?: number; + data?: { + error?: string; + message?: string; + request_id?: string; + retry_after?: number; + details?: Record; + }; + }; + message?: string; + }; + const status = anyErr?.response?.status; + const body = anyErr?.response?.data; + + if (status && body && typeof body.error === "string") { + return new ActaError({ + code: body.error, + httpStatus: status, + message: body.message || body.error, + requestId: body.request_id, + retryAfter: body.retry_after, + details: body.details, + }); + } + + return new ActaError({ + code: status ? "bad_request" : "network_error", + httpStatus: status ?? 0, + message: + anyErr?.message || (status ? `HTTP ${status}` : "Network request failed"), + }); +} diff --git a/src/utils/contract-limits.ts b/src/utils/contract-limits.ts new file mode 100644 index 0000000..e87b1a0 --- /dev/null +++ b/src/utils/contract-limits.ts @@ -0,0 +1,35 @@ +/** + * On-chain limits exposed by `vc-vault` v0.3.0 and `vc-issuer-registry`. + * + * Mirrors the constants in `contracts-acta/contracts/vc-vault/src/constants.rs` + * (and the equivalent in `vc-issuer-registry/src/contract.rs`). Exported so + * UI components and validation layers can stay in sync with the contract + * without each consumer maintaining its own copy. + * + * Keep these values aligned with the contracts — they are NOT enforced + * client-side by the SDK; they are guidance. The API and the contract both + * reject anything that exceeds them. + */ + +export const CONTRACT_LIMITS = { + /** Hard cap on `limit` for any paginated listing (`list_vc_ids`, issuers). */ + MAX_LIST_LIMIT: 200, + /** Maximum credentials per `batch_issue` call. */ + MAX_BATCH_SIZE: 5, + /** Maximum bytes for `vc_id` strings. */ + MAX_VC_ID_LEN: 64, + /** Maximum bytes for `vc_data` payloads. */ + MAX_VC_DATA_LEN: 10_000, + /** Maximum bytes for vault `did_uri`. */ + MAX_DID_URI_LEN: 256, + /** Maximum bytes for `issuer_did`. */ + MAX_ISSUER_DID_LEN: 256, + /** Maximum bytes for revocation `date` strings (ISO 8601). */ + MAX_DATE_LEN: 64, + /** Maximum number of addresses accepted by `authorize_issuers(list)`. */ + MAX_ISSUERS_LIST: 100, + /** Maximum bytes for `vc-issuer-registry` metadata fields (`did`, `url`). */ + MAX_ISSUER_REGISTRY_METADATA_BYTES: 256, +} as const; + +export type ContractLimits = typeof CONTRACT_LIMITS;