From 88b8f56b38161ac5447a0e19cba395e7c330512d Mon Sep 17 00:00:00 2001 From: Angel Calderaro Date: Wed, 13 May 2026 09:06:04 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(server-sdk):=20CrossmintTreasury=20?= =?UTF-8?q?=E2=80=94=20B2B=20Treasury=20REST=20SDK=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Region-agnostic server-side SDK for the 2026-05-11/treasury/* API. Mirrors the CrossmintAuth shape: from(crossmint) factory, apiClient-driven, typed errors. Server-key only (rejects ck_* at construction). Methods: - createPayout(req, { idempotencyKey? }) / getPayout(id) - createOfframp(req, { idempotencyKey? }) / getOfframp(id) - registerHifiOfframpAccount(req) — US rail - registerOpenPaydBeneficiary(req) — EU rail - listAccounts() / getBalances() / listTransactions({ kind?, limit?, cursor? }) Highlights: - Idempotency-Key built in. Auto-gen UUIDv4 per write; caller can override for derived-key lineage. - Public types defined locally in treasury/types.ts (no dep on the backend monorepo's @crossmint/products-payments-types workspace package). - CrossmintTreasuryError carries stable `code` field — match on code, not message. Common codes documented inline. - TREASURY_API_VERSION = "2026-05-11" pinned. Future contract bumps land as parallel class surfaces, not mutations. Tests: 11 new in CrossmintTreasury.test.ts (Idempotency-Key header, URL-encoding, query-string building, error-shape decoding, raw-payload preservation on typed errors). 47 total @crossmint/server-sdk tests green (was 36, +11). --- .changeset/treasury-sdk-surface.md | 5 + packages/server/src/index.ts | 1 + .../src/treasury/CrossmintTreasury.test.ts | 289 ++++++++++++++++++ .../server/src/treasury/CrossmintTreasury.ts | 230 ++++++++++++++ packages/server/src/treasury/errors.ts | 31 ++ packages/server/src/treasury/index.ts | 4 + packages/server/src/treasury/types.ts | 217 +++++++++++++ 7 files changed, 777 insertions(+) create mode 100644 .changeset/treasury-sdk-surface.md create mode 100644 packages/server/src/treasury/CrossmintTreasury.test.ts create mode 100644 packages/server/src/treasury/CrossmintTreasury.ts create mode 100644 packages/server/src/treasury/errors.ts create mode 100644 packages/server/src/treasury/index.ts create mode 100644 packages/server/src/treasury/types.ts diff --git a/.changeset/treasury-sdk-surface.md b/.changeset/treasury-sdk-surface.md new file mode 100644 index 000000000..b947dbe6c --- /dev/null +++ b/.changeset/treasury-sdk-surface.md @@ -0,0 +1,5 @@ +--- +"@crossmint/server-sdk": minor +--- + +Add `CrossmintTreasury` server-side SDK for the B2B Treasury REST surface (`2026-05-11/treasury/*`). Region-agnostic methods: `createPayout` / `getPayout`, `createOfframp` / `getOfframp`, `registerHifiOfframpAccount`, `registerOpenPaydBeneficiary`, `listAccounts`, `getBalances`, `listTransactions`. Auto-generates `Idempotency-Key` UUIDs on writes (caller can override). Server-key-only (`sk_*`) — rejects client keys at construction. Typed `CrossmintTreasuryError` with stable `code` field for status-code-based error handling. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a17325207..e634d1e0e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,2 +1,3 @@ export { createCrossmint } from "@crossmint/common-sdk-base"; export * from "./auth"; +export * from "./treasury"; diff --git a/packages/server/src/treasury/CrossmintTreasury.test.ts b/packages/server/src/treasury/CrossmintTreasury.test.ts new file mode 100644 index 000000000..08df127e0 --- /dev/null +++ b/packages/server/src/treasury/CrossmintTreasury.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { type Crossmint, CrossmintApiClient } from "@crossmint/common-sdk-base"; + +import { CrossmintTreasury, TREASURY_API_VERSION } from "./CrossmintTreasury"; +import { CrossmintTreasuryError } from "./errors"; + +vi.mock("@crossmint/common-sdk-base"); + +function makeResponse(body: unknown, init: { status?: number; statusText?: string } = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + statusText: init.statusText ?? "OK", + headers: { "content-type": "application/json" }, + }); +} + +describe("CrossmintTreasury", () => { + const mockCrossmint = { projectId: "test-project-id", apiKey: "sk_staging_xxx" } as unknown as Crossmint; + const apiClient = { + baseUrl: "https://staging.crossmint.com", + get: vi.fn(), + post: vi.fn(), + }; + let treasury: CrossmintTreasury; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(CrossmintApiClient).mockReturnValue(apiClient as unknown as CrossmintApiClient); + treasury = CrossmintTreasury.from(mockCrossmint, { idempotencyKeyFn: () => "test-idem-key" }); + }); + + describe("createPayout", () => { + it("POSTs to the dated treasury/payouts path with an Idempotency-Key header", async () => { + apiClient.post.mockResolvedValueOnce( + makeResponse({ + id: "payout-1", + status: "pending", + amount: { value: "10", currency: "usdc" }, + destination: { type: "wallet", chain: "polygon", walletAddress: "0xabc" }, + region: "us", + vendor: "hifi", + createdAt: "2026-05-13T00:00:00Z", + updatedAt: "2026-05-13T00:00:00Z", + }) + ); + + const result = await treasury.createPayout({ + amount: { value: "10", currency: "usdc" }, + destination: { type: "wallet", chain: "polygon", walletAddress: "0xabc" }, + }); + + expect(apiClient.post).toHaveBeenCalledWith( + `api/${TREASURY_API_VERSION}/treasury/payouts`, + expect.objectContaining({ + headers: expect.objectContaining({ "Idempotency-Key": "test-idem-key" }), + body: expect.any(String), + }) + ); + expect(result.id).toBe("payout-1"); + expect(result.status).toBe("pending"); + }); + + it("honors caller-supplied idempotency key over the auto-generated default", async () => { + apiClient.post.mockResolvedValueOnce(makeResponse({ id: "payout-2", status: "completed" })); + + await treasury.createPayout( + { + amount: { value: "5", currency: "usdc" }, + destination: { type: "wallet", chain: "polygon", walletAddress: "0xdef" }, + }, + { idempotencyKey: "caller-supplied-key" } + ); + + expect(apiClient.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ "Idempotency-Key": "caller-supplied-key" }), + }) + ); + }); + }); + + describe("getPayout", () => { + it("GETs the payout by id with URL-encoded path segment", async () => { + apiClient.get.mockResolvedValueOnce(makeResponse({ id: "payout-3", status: "completed" })); + + await treasury.getPayout("payout 3 with spaces"); + + expect(apiClient.get).toHaveBeenCalledWith( + `api/${TREASURY_API_VERSION}/treasury/payouts/${encodeURIComponent("payout 3 with spaces")}`, + expect.any(Object) + ); + }); + }); + + describe("createOfframp", () => { + it("POSTs to treasury/offramps with the request body + idempotency-key", async () => { + apiClient.post.mockResolvedValueOnce( + makeResponse({ + id: "off-1", + status: "pending", + amount: { value: "100", currency: "usdc" }, + destinationAccountId: "acc-1", + sourceChain: "polygon", + region: "us", + vendor: "hifi", + createdAt: "2026-05-13T00:00:00Z", + updatedAt: "2026-05-13T00:00:00Z", + }) + ); + + const result = await treasury.createOfframp({ + amount: { value: "100", currency: "usdc" }, + destinationAccountId: "acc-1", + sourceChain: "polygon", + }); + + expect(apiClient.post).toHaveBeenCalledWith( + `api/${TREASURY_API_VERSION}/treasury/offramps`, + expect.objectContaining({ + headers: expect.objectContaining({ "Idempotency-Key": "test-idem-key" }), + }) + ); + expect(result.id).toBe("off-1"); + }); + }); + + describe("registerHifiOfframpAccount", () => { + it("POSTs to treasury/offramp-accounts (HiFi rail)", async () => { + apiClient.post.mockResolvedValueOnce( + makeResponse({ + id: "acc-1", + bankName: "Acme Bank", + last4: "7890", + transferType: "ach", + accountType: "Checking", + currency: "usd", + status: "active", + }) + ); + + const result = await treasury.registerHifiOfframpAccount({ + transferType: "ach", + accountType: "Checking", + accountNumber: "1234567890", + routingNumber: "021000021", + bankName: "Acme Bank", + accountHolderName: "Acme Inc", + address: { + addressLine1: "1 Main", + city: "NYC", + stateProvinceRegion: "NY", + postalCode: "10001", + country: "US", + }, + }); + + expect(apiClient.post).toHaveBeenCalledWith( + `api/${TREASURY_API_VERSION}/treasury/offramp-accounts`, + expect.any(Object) + ); + expect(result.last4).toBe("7890"); + }); + }); + + describe("registerOpenPaydBeneficiary", () => { + it("POSTs to treasury/beneficiaries (OpenPayd rail)", async () => { + apiClient.post.mockResolvedValueOnce( + makeResponse({ + id: "ben-1", + bankAccountHolderName: "Acme EU GmbH", + bankAccountCountry: "DE", + currency: "EUR", + last4: "3000", + bic: "COBADEFFXXX", + status: "active", + }) + ); + + const result = await treasury.registerOpenPaydBeneficiary({ + bankAccountHolderName: "Acme EU GmbH", + bankAccountCountry: "DE", + currency: "EUR", + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + }); + + expect(result.id).toBe("ben-1"); + }); + }); + + describe("listTransactions", () => { + it("passes kind/limit/cursor as query params", async () => { + apiClient.get.mockResolvedValueOnce(makeResponse({ items: [] })); + + await treasury.listTransactions({ kind: "payout", limit: 50, cursor: "abc" }); + + expect(apiClient.get).toHaveBeenCalledWith( + `api/${TREASURY_API_VERSION}/treasury/transactions?kind=payout&limit=50&cursor=abc`, + expect.any(Object) + ); + }); + + it("omits the query string entirely when no filters are passed", async () => { + apiClient.get.mockResolvedValueOnce(makeResponse({ items: [] })); + + await treasury.listTransactions(); + + expect(apiClient.get).toHaveBeenCalledWith( + `api/${TREASURY_API_VERSION}/treasury/transactions`, + expect.any(Object) + ); + }); + }); + + describe("error handling", () => { + it("throws CrossmintTreasuryError with code + status on 4xx", async () => { + apiClient.post.mockResolvedValueOnce( + makeResponse( + { + code: "treasury.idempotency.in_flight", + message: "Concurrent request with same Idempotency-Key is still processing", + }, + { status: 409, statusText: "Conflict" } + ) + ); + + await expect( + treasury.createPayout({ + amount: { value: "1", currency: "usdc" }, + destination: { type: "wallet", chain: "polygon", walletAddress: "0x" }, + }) + ).rejects.toMatchObject({ + name: "CrossmintTreasuryError", + code: "treasury.idempotency.in_flight", + status: 409, + }); + }); + + it("surfaces a generic code when the error body is not JSON", async () => { + apiClient.post.mockResolvedValueOnce( + new Response("502 Bad Gateway", { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }) + ); + + try { + await treasury.createPayout({ + amount: { value: "1", currency: "usdc" }, + destination: { type: "wallet", chain: "polygon", walletAddress: "0x" }, + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CrossmintTreasuryError); + expect((err as CrossmintTreasuryError).code).toBe("treasury.unknown_error"); + expect((err as CrossmintTreasuryError).status).toBe(502); + } + }); + + it("preserves the raw error payload on the thrown error for advanced inspection", async () => { + apiClient.post.mockResolvedValueOnce( + makeResponse( + { + code: "treasury.offramp.eu_workflow_pending", + message: "EU offramp service layer is ready; Temporal workflow + on-chain executor wiring lands in 9.2-apps", + region: "eu", + vendor: "openpayd", + }, + { status: 501 } + ) + ); + + try { + await treasury.createOfframp({ + amount: { value: "100", currency: "usdc" }, + destinationAccountId: "ben-1", + sourceChain: "polygon", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CrossmintTreasuryError); + expect((err as CrossmintTreasuryError).code).toBe("treasury.offramp.eu_workflow_pending"); + expect((err as CrossmintTreasuryError).raw).toMatchObject({ region: "eu", vendor: "openpayd" }); + } + }); + }); +}); diff --git a/packages/server/src/treasury/CrossmintTreasury.ts b/packages/server/src/treasury/CrossmintTreasury.ts new file mode 100644 index 000000000..0c230192a --- /dev/null +++ b/packages/server/src/treasury/CrossmintTreasury.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import { + type Crossmint, + type CrossmintApiClient, + CrossmintApiClient as CrossmintApiClientCtor, +} from "@crossmint/common-sdk-base"; + +import { CrossmintTreasuryError } from "./errors"; +import type { + CreateTreasuryOfframpRequest, + CreateTreasuryPayoutRequest, + ListTreasuryAccountsResponse, + ListTreasuryBalancesResponse, + ListTreasuryTransactionsQuery, + ListTreasuryTransactionsResponse, + RegisterOfframpAccountRequest, + RegisterOpenPaydBeneficiaryRequest, + RegisteredOfframpAccount, + RegisteredOpenPaydBeneficiary, + TreasuryOfframp, + TreasuryPayout, +} from "./types"; + +/** + * REST API version pinned to the date that landed the public Treasury + * surface (see crossbit-main commits 6b7677265f / 482d82e4b8 / 9b2d186f29). + * When the backend bumps to a later dated version, add a new class + * surface (e.g. CrossmintTreasury2027) rather than mutating this one — + * SDK consumers pin versions and a version bump on the wire is a + * breaking change. + */ +export const TREASURY_API_VERSION = "2026-05-11"; + +const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; + +export interface CrossmintTreasuryOptions { + /** + * Override the auto-generated UUID idempotency key generator. Useful for + * tests + for callers that want to carry their own idempotency-key + * lineage (e.g. derived from upstream request id). + */ + idempotencyKeyFn?: () => string; +} + +/** + * Server-side SDK for the Crossmint B2B Treasury API. Region-agnostic: + * routing to HiFi (US) vs OpenPayd (EU) happens server-side based on the + * project's configured region. The SDK surface is uniform across + * regions, but some operations return 501 / 400 from regions that + * haven't lit up that flow yet — match on the `code` field of + * `CrossmintTreasuryError` to handle. + * + * Auth: server keys only (`sk_*`). The Treasury surface uses + * `TREASURY_READ` / `TREASURY_WRITE` API key scopes; passing a client + * key (`ck_*`) will be rejected by the API. + */ +export class CrossmintTreasury { + private readonly idempotencyKeyFn: () => string; + + private constructor( + private readonly apiClient: CrossmintApiClient, + options: CrossmintTreasuryOptions = {} + ) { + this.idempotencyKeyFn = options.idempotencyKeyFn ?? randomUUID; + } + + public static from(crossmint: Crossmint, options: CrossmintTreasuryOptions = {}): CrossmintTreasury { + const apiClient = new CrossmintApiClientCtor(crossmint, { + internalConfig: { + sdkMetadata: { name: "@crossmint/server-sdk/treasury", version: TREASURY_API_VERSION }, + apiKeyExpectations: { usageOrigin: "server" }, + }, + }); + return new CrossmintTreasury(apiClient, options); + } + + // ----------------------------------------------------------------------- + // Payouts (R0) + // ----------------------------------------------------------------------- + + /** + * Initiate a regulated payout to an external wallet. Idempotent on the + * `Idempotency-Key` header — caller can replay the same key with the + * same body to dedup; replaying with a different body returns a 409 + * `treasury.idempotency.body_mismatch`. + */ + async createPayout( + request: CreateTreasuryPayoutRequest, + opts: { idempotencyKey?: string } = {} + ): Promise { + return this.write(`api/${TREASURY_API_VERSION}/treasury/payouts`, request, opts); + } + + async getPayout(payoutId: string): Promise { + return this.read(`api/${TREASURY_API_VERSION}/treasury/payouts/${encodeURIComponent(payoutId)}`); + } + + // ----------------------------------------------------------------------- + // Offramps (R2) + // ----------------------------------------------------------------------- + + /** + * Initiate a crypto → fiat offramp. On HiFi (US) the call is quote+accept + * atomic. On OpenPayd (EU) the saga returns 501 `eu_workflow_pending` + * until the apps-layer chain executor is wired (Phase 9.2-apps). + */ + async createOfframp( + request: CreateTreasuryOfframpRequest, + opts: { idempotencyKey?: string } = {} + ): Promise { + return this.write(`api/${TREASURY_API_VERSION}/treasury/offramps`, request, opts); + } + + async getOfframp(offrampId: string): Promise { + return this.read( + `api/${TREASURY_API_VERSION}/treasury/offramps/${encodeURIComponent(offrampId)}` + ); + } + + /** + * Register a destination bank account for HiFi offramps. Returns the + * persisted account id; pass that id as `destinationAccountId` on + * subsequent `createOfframp` calls. + */ + async registerHifiOfframpAccount(request: RegisterOfframpAccountRequest): Promise { + return this.write( + `api/${TREASURY_API_VERSION}/treasury/offramp-accounts`, + request + ); + } + + /** + * Register a destination bank account for OpenPayd offramps. EU + * equivalent of `registerHifiOfframpAccount`. Returns the persisted + * beneficiary id; pass that as `destinationAccountId` on + * `createOfframp`. + */ + async registerOpenPaydBeneficiary( + request: RegisterOpenPaydBeneficiaryRequest + ): Promise { + return this.write( + `api/${TREASURY_API_VERSION}/treasury/beneficiaries`, + request + ); + } + + // ----------------------------------------------------------------------- + // Read endpoints + // ----------------------------------------------------------------------- + + async listAccounts(): Promise { + return this.read(`api/${TREASURY_API_VERSION}/treasury/accounts`); + } + + async getBalances(): Promise { + return this.read(`api/${TREASURY_API_VERSION}/treasury/balances`); + } + + async listTransactions( + query: ListTreasuryTransactionsQuery = {} + ): Promise { + const params = new URLSearchParams(); + if (query.kind != null) { + params.set("kind", query.kind); + } + if (query.limit != null) { + params.set("limit", String(query.limit)); + } + if (query.cursor != null) { + params.set("cursor", query.cursor); + } + const qs = params.toString(); + const path = `api/${TREASURY_API_VERSION}/treasury/transactions${qs ? `?${qs}` : ""}`; + return this.read(path); + } + + // ----------------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------------- + + private async write( + path: string, + body: unknown, + opts: { idempotencyKey?: string } = {} + ): Promise { + const idempotencyKey = opts.idempotencyKey ?? this.idempotencyKeyFn(); + const response = await this.apiClient.post(path, { + headers: { + "Content-Type": "application/json", + [IDEMPOTENCY_KEY_HEADER]: idempotencyKey, + }, + body: JSON.stringify(body), + }); + return this.unwrap(response); + } + + private async read(path: string): Promise { + const response = await this.apiClient.get(path, { + headers: { "Content-Type": "application/json" }, + }); + return this.unwrap(response); + } + + private async unwrap(response: Response): Promise { + if (response.ok) { + return (await response.json()) as T; + } + // 4xx surfaces the controller's `{ code, message, ... }` shape. + // Read defensively — some upstream errors (e.g. nginx 502) won't + // be JSON, in which case we fall back to a generic code. + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + throw new CrossmintTreasuryError( + `Treasury API request failed: ${response.status} ${response.statusText}`, + "treasury.unknown_error", + response.status, + null + ); + } + const shape = parsed as { code?: unknown; message?: unknown }; + const code = typeof shape.code === "string" ? shape.code : "treasury.unknown_error"; + const message = + typeof shape.message === "string" + ? shape.message + : `Treasury API request failed: ${response.status} ${response.statusText}`; + throw new CrossmintTreasuryError(message, code, response.status, parsed); + } +} diff --git a/packages/server/src/treasury/errors.ts b/packages/server/src/treasury/errors.ts new file mode 100644 index 000000000..f2226805c --- /dev/null +++ b/packages/server/src/treasury/errors.ts @@ -0,0 +1,31 @@ +/** + * Typed error surfaced when the Treasury API returns a non-2xx response. + * Mirrors the controller-side error shape: `{ code, message }` plus + * optional fields per surface (e.g. idempotency-conflict surfaces the + * original status, region/vendor restriction surfaces those values). + * + * Consumers should match on `code` rather than `message` — message text + * may evolve, codes are stable. + * + * Common codes (non-exhaustive): + * - `treasury.idempotency.in_flight` — 409, retry shortly + * - `treasury.idempotency.body_mismatch` — 409, same key used with different body + * - `treasury.payout.region_not_supported` — 400, route doesn't support payouts + * - `treasury.payout.context_missing` — 404, GET-able only via the new GET wiring + * - `treasury.offramp.region_not_supported` + * - `treasury.offramp.eu_workflow_pending` — 501, EU saga apps-layer not yet wired + * - `treasury.offramp.context_missing` + * - `treasury.user.not_provisioned` — 400, complete onboarding first + * - `treasury.chain.unsupported` — 400 + */ +export class CrossmintTreasuryError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly status: number, + public readonly raw: unknown + ) { + super(message); + this.name = "CrossmintTreasuryError"; + } +} diff --git a/packages/server/src/treasury/index.ts b/packages/server/src/treasury/index.ts new file mode 100644 index 000000000..c2b2cb265 --- /dev/null +++ b/packages/server/src/treasury/index.ts @@ -0,0 +1,4 @@ +export { CrossmintTreasury, TREASURY_API_VERSION } from "./CrossmintTreasury"; +export type { CrossmintTreasuryOptions } from "./CrossmintTreasury"; +export { CrossmintTreasuryError } from "./errors"; +export * from "./types"; diff --git a/packages/server/src/treasury/types.ts b/packages/server/src/treasury/types.ts new file mode 100644 index 000000000..55d5024a5 --- /dev/null +++ b/packages/server/src/treasury/types.ts @@ -0,0 +1,217 @@ +/** + * Public types for the B2B Treasury surface. These mirror the + * `2026-05-11/treasury/*` REST contracts; defined locally rather than + * imported from the backend monorepo since `@crossmint/server-sdk` ships + * to public npm without that workspace dependency. + * + * Keep these in sync with `crossbit-main/libraries/products/payments/types/ + * src/treasury/TreasuryApi.ts`. When the backend contract bumps to a new + * dated version, add a parallel set here under the new version namespace — + * don't mutate the existing one (consumers pin SDK versions). + */ + +export type TreasuryRegion = "us" | "eu"; +export type TreasuryVendor = "hifi" | "openpayd"; + +export type TreasuryChain = "polygon" | "ethereum" | "solana" | "base" | "arbitrum" | "tron" | "stellar"; + +export interface TreasuryAmount { + value: string; + currency: string; +} + +// --------------------------------------------------------------------------- +// Payouts (R0 — `POST /treasury/payouts`) +// --------------------------------------------------------------------------- + +export type TreasuryPayoutStatus = + | "pending" + | "processing" + | "completed" + | "failed" + | "requires_approval"; + +export interface TreasuryPayoutDestination { + type: "wallet"; + chain: TreasuryChain; + walletAddress: string; + userId?: string; +} + +export interface CreateTreasuryPayoutRequest { + amount: TreasuryAmount; + destination: TreasuryPayoutDestination; + requireApproval?: boolean; + purposeOfPayment?: string; +} + +export interface TreasuryPayout { + id: string; + status: TreasuryPayoutStatus; + amount: TreasuryAmount; + destination: TreasuryPayoutDestination; + region: TreasuryRegion; + vendor: TreasuryVendor; + createdAt: string; + updatedAt: string; + failureReason?: string; +} + +// --------------------------------------------------------------------------- +// Offramps (R2 — `POST /treasury/offramps`) +// --------------------------------------------------------------------------- + +export type TreasuryOfframpStatus = + | "quoted" + | "pending" + | "processing" + | "completed" + | "failed" + | "returned"; + +export interface CreateTreasuryOfframpRequest { + amount: TreasuryAmount; + destinationAccountId: string; + sourceChain: TreasuryChain; + purposeOfPayment?: string; + sameDayAch?: boolean; + supportingDocumentType?: string; + supportingDocumentUrl?: string; + description?: string; +} + +export interface TreasuryOfframpQuote { + sendGross: string; + sendNet: string; + receiveGross: string; + receiveNet: string; + rate: string; + expiresAt: string; +} + +export interface TreasuryOfframp { + id: string; + status: TreasuryOfframpStatus; + amount: TreasuryAmount; + destinationAccountId: string; + sourceChain: TreasuryChain; + region: TreasuryRegion; + vendor: TreasuryVendor; + purposeOfPayment?: string; + quote?: TreasuryOfframpQuote; + createdAt: string; + updatedAt: string; + failureReason?: string; +} + +// --------------------------------------------------------------------------- +// Read endpoints +// --------------------------------------------------------------------------- + +export interface TreasuryAccount { + id: string; + region: TreasuryRegion; + vendor: TreasuryVendor; + status: string; + /** Vendor-shaped deposit instructions (IBAN/BIC for OpenPayd; routing+account for HiFi). */ + depositInstructions: unknown; + createdAt: string; +} + +export interface ListTreasuryAccountsResponse { + items: TreasuryAccount[]; +} + +export interface TreasuryBalance { + chain: TreasuryChain; + currency: string; + available: string; + pending: string; +} + +export interface ListTreasuryBalancesResponse { + balances: TreasuryBalance[]; +} + +export type TreasuryTransactionKind = "deposit" | "transfer" | "onramp" | "offramp" | "payout"; + +export interface TreasuryTransaction { + id: string; + kind: TreasuryTransactionKind; + status: string; + amount: TreasuryAmount; + chain: string; + createdAt: string; +} + +export interface ListTreasuryTransactionsResponse { + items: TreasuryTransaction[]; + nextCursor?: string; +} + +export interface ListTreasuryTransactionsQuery { + kind?: TreasuryTransactionKind; + limit?: number; + cursor?: string; +} + +// --------------------------------------------------------------------------- +// Offramp account registration (HiFi side — `POST /offramp-accounts`) +// --------------------------------------------------------------------------- + +export interface RegisterOfframpAccountRequest { + transferType: "ach" | "wire" | "swift"; + accountType: "Checking" | "Savings"; + accountNumber: string; + routingNumber: string; + bankName: string; + accountHolderName: string; + accountHolderType?: "individual" | "business"; + address: { + addressLine1: string; + addressLine2?: string; + city: string; + stateProvinceRegion: string; + postalCode: string; + country: string; + }; +} + +export interface RegisteredOfframpAccount { + id: string; + bankName: string; + last4: string; + transferType: "ach" | "wire" | "swift"; + accountType: "Checking" | "Savings"; + currency: "usd"; + status: "active" | "closed"; +} + +// --------------------------------------------------------------------------- +// Beneficiary registration (OpenPayd side — `POST /beneficiaries`) +// --------------------------------------------------------------------------- + +export interface RegisterOpenPaydBeneficiaryRequest { + bankAccountHolderName: string; + bankAccountCountry: string; + currency: "USD" | "EUR" | "GBP"; + bankName?: string; + bankAddress?: string; + /** SEPA — supply iban + bic. */ + iban?: string; + bic?: string; + /** Non-IBAN rails — supply accountNumber + routing codes. */ + accountNumber?: string; + bankRoutingCodes?: Array<{ routingCodeKey: string; routingCodeValue: string }>; + friendlyName?: string; +} + +export interface RegisteredOpenPaydBeneficiary { + id: string; + bankAccountHolderName: string; + bankAccountCountry: string; + currency: "USD" | "EUR" | "GBP"; + last4: string; + bic: string | null; + status: "active" | "closed"; +} From faff008620e314b3d045b5663bce355b47767f62 Mon Sep 17 00:00:00 2001 From: Angel Calderaro Date: Thu, 14 May 2026 17:50:17 -0600 Subject: [PATCH 2/2] =?UTF-8?q?feat(treasury):=20client-side=20read=20SDK?= =?UTF-8?q?=20+=20refresh=20server=20error=20codes=20(Phase=2010.2=20?= =?UTF-8?q?=E2=80=94=20SDK=20delta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the server-sdk Treasury surface (88b8f56b). Two pieces: (1) New `createCrossmintTreasuryClient` factory in client-sdk-base. Read-only methods (getPayout, getOfframp, listTransactions, getBalances, listAccounts) for browser / RN flows that hold a `ck_*` client key and just need to poll status. Writes stay server-side where compliance + idempotency belong. Follows the existing factory-pattern of paymentMethodManagementService — consumers pass a pre-constructed CrossmintApiClient, the factory returns the typed surface. Types are loose `unknown` projections here — the client polling facade returns the server-shape directly; consumers typically already have the typed shapes from @crossmint/server-sdk/treasury or their own backend response types. Keeping types minimal avoids a dep edge from client-base to server-sdk. (2) Refresh server errors.ts with the codes added in crossbit-main Phase 9.2-apps (`1edfd638dc`) and 11.3 (`8ccee1c7f1`): - treasury.offramp.currency_unsupported (400) - treasury.account.not_provisioned (400, EU path) - treasury.account.misconfigured (400, missing env) - treasury.account.unavailable (500) - treasury.region.* / treasury.route.unsupported (400/500) - treasury.settlement_chain.unresolved (400) Documented that treasury.offramp.eu_workflow_pending was removed in 9.2-apps — the EU saga is now live and returns a real `pending` envelope. Not in scope (deferred): - EU offramp Model A two-call wrapper (createOfframpWithSignerPrompt) — backend currently kicks off the workflow on POST without a separate /authorize endpoint, per the 9.2-apps simplification. If Model A surfaces as load-bearing later, expose the wrapper then. - Treasury write methods on the client SDK — intentional; writes belong on the server boundary. --- packages/client/base/src/services/index.ts | 1 + .../crossmintTreasuryClientService.ts | 86 +++++++++++++++++++ .../base/src/services/treasury/index.ts | 1 + packages/server/src/treasury/errors.ts | 30 +++++-- 4 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 packages/client/base/src/services/treasury/crossmintTreasuryClientService.ts create mode 100644 packages/client/base/src/services/treasury/index.ts diff --git a/packages/client/base/src/services/index.ts b/packages/client/base/src/services/index.ts index 958442374..93d6b0823 100644 --- a/packages/client/base/src/services/index.ts +++ b/packages/client/base/src/services/index.ts @@ -1,6 +1,7 @@ export * from "./embed"; export * from "./hosted"; export * from "./payment-method-management"; +export * from "./treasury"; export * from "./api"; export * from "./logging"; diff --git a/packages/client/base/src/services/treasury/crossmintTreasuryClientService.ts b/packages/client/base/src/services/treasury/crossmintTreasuryClientService.ts new file mode 100644 index 000000000..8c1a411aa --- /dev/null +++ b/packages/client/base/src/services/treasury/crossmintTreasuryClientService.ts @@ -0,0 +1,86 @@ +import type { CrossmintApiClient } from "@crossmint/common-sdk-base"; + +/** + * Read-only client-side surface over the Crossmint Treasury REST API. + * + * Companion to `@crossmint/server-sdk/treasury` (which exposes the full + * write surface and requires `sk_*` server keys). This client variant is + * for browser / RN flows that hold a `ck_*` client key and just need to + * poll status — e.g. an EU offramp UI that submits the create call + * server-side, then watches `getOfframp` while the saga runs. + * + * Writes (createPayout, createOfframp, registerHifiOfframpAccount, + * registerOpenPaydBeneficiary) are intentionally NOT exposed here — + * those carry compliance + idempotency responsibilities that belong on + * a server boundary. If a UI build needs to drive a write, do it + * through your own server proxy that calls `@crossmint/server-sdk`. + * + * API version pinned to the date that landed the public Treasury surface + * (matches `TREASURY_API_VERSION` in `@crossmint/server-sdk/treasury`). + * When the backend bumps the dated version, add a parallel client + * factory rather than mutating this one — consumers pin SDK versions. + */ +export const TREASURY_API_VERSION = "2026-05-11"; + +export interface CrossmintTreasuryClientServiceProps { + apiClient: CrossmintApiClient; +} + +// Types are loose `unknown` projections here — the client-side surface +// is just a polling read facade and consumers typically already have +// the typed shapes from `@crossmint/server-sdk/treasury` or their own +// backend response types. Keeping types minimal here avoids a wide dep +// edge from client-base to server-sdk. +export interface CrossmintTreasuryClient { + getPayout(payoutId: string): Promise; + getOfframp(offrampId: string): Promise; + listTransactions(query?: { kind?: string; limit?: number; cursor?: string }): Promise; + getBalances(): Promise; + listAccounts(): Promise; +} + +export function createCrossmintTreasuryClient({ + apiClient, +}: CrossmintTreasuryClientServiceProps): CrossmintTreasuryClient { + async function read(path: string): Promise { + const response = await apiClient.get(path, { + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Treasury API request failed: ${response.status} ${response.statusText}${text ? ` — ${text}` : ""}` + ); + } + return (await response.json()) as T; + } + + return { + async getPayout(payoutId) { + return read(`api/${TREASURY_API_VERSION}/treasury/payouts/${encodeURIComponent(payoutId)}`); + }, + async getOfframp(offrampId) { + return read(`api/${TREASURY_API_VERSION}/treasury/offramps/${encodeURIComponent(offrampId)}`); + }, + async listTransactions(query = {}) { + const params = new URLSearchParams(); + if (query.kind != null) { + params.set("kind", query.kind); + } + if (query.limit != null) { + params.set("limit", String(query.limit)); + } + if (query.cursor != null) { + params.set("cursor", query.cursor); + } + const qs = params.toString(); + return read(`api/${TREASURY_API_VERSION}/treasury/transactions${qs ? `?${qs}` : ""}`); + }, + async getBalances() { + return read(`api/${TREASURY_API_VERSION}/treasury/balances`); + }, + async listAccounts() { + return read(`api/${TREASURY_API_VERSION}/treasury/accounts`); + }, + }; +} diff --git a/packages/client/base/src/services/treasury/index.ts b/packages/client/base/src/services/treasury/index.ts new file mode 100644 index 000000000..416f92040 --- /dev/null +++ b/packages/client/base/src/services/treasury/index.ts @@ -0,0 +1 @@ +export * from "./crossmintTreasuryClientService"; diff --git a/packages/server/src/treasury/errors.ts b/packages/server/src/treasury/errors.ts index f2226805c..41cbc0485 100644 --- a/packages/server/src/treasury/errors.ts +++ b/packages/server/src/treasury/errors.ts @@ -8,15 +8,27 @@ * may evolve, codes are stable. * * Common codes (non-exhaustive): - * - `treasury.idempotency.in_flight` — 409, retry shortly - * - `treasury.idempotency.body_mismatch` — 409, same key used with different body - * - `treasury.payout.region_not_supported` — 400, route doesn't support payouts - * - `treasury.payout.context_missing` — 404, GET-able only via the new GET wiring - * - `treasury.offramp.region_not_supported` - * - `treasury.offramp.eu_workflow_pending` — 501, EU saga apps-layer not yet wired - * - `treasury.offramp.context_missing` - * - `treasury.user.not_provisioned` — 400, complete onboarding first - * - `treasury.chain.unsupported` — 400 + * - `treasury.idempotency.in_flight` — 409, retry shortly + * - `treasury.idempotency.body_mismatch` — 409, same key used with different body + * - `treasury.payout.region_not_supported` — 400, route doesn't support payouts + * - `treasury.payout.context_missing` — 404, GET-able only via the new GET wiring + * - `treasury.offramp.region_not_supported` — 400 + * - `treasury.offramp.context_missing` — 404 + * - `treasury.offramp.currency_unsupported` — 400, EU offramp only USDC or EURC source + * - `treasury.user.not_provisioned` — 400, complete US onboarding (Phase 2.4/2.7) first + * - `treasury.account.not_provisioned` — 400, complete EU provisioning (Phase 3.1) first + * - `treasury.account.misconfigured` — 400, missing server env (e.g. OpenPayd parent account holder id) + * - `treasury.account.unavailable` — 500, vendor returned unavailable + * - `treasury.chain.unsupported` — 400 + * - `treasury.region.unresolved` — 400, project missing treasuryRegions and no legacy fallback + * - `treasury.region.invariant_violation` — 500, server-asserted single-region rule violated + * - `treasury.settlement_chain.unresolved` — 400 + * - `treasury.route.unsupported` — 400, (region, vendor) tuple has no adapter + * + * Note: `treasury.offramp.eu_workflow_pending` was removed in Phase 9.2-apps + * — the EU saga is now live; an EU offramp returns a real `pending` envelope + * rather than a 501. If you see it surface in older SDK versions or stale + * environments, upgrade the backend. */ export class CrossmintTreasuryError extends Error { constructor(