UNPKG

@coinbase/cdp-sdk

Version:

SDK for interacting with the Coinbase Developer Platform Wallet API

760 lines (697 loc) • 28.5 kB
/* * Wires a SpendControls configuration onto an upstream x402Client. */ import { PaymentRequirementsV1Schema, PaymentRequirementsV2Schema } from "@x402/core/schemas"; import { normalizeAsset, normalizeNetwork, normalizePayee } from "./normalize.js"; import { SpendTracker } from "./spend-tracker.js"; import { parseAmount, parseDuration, SpendControlError, type Address, type Amount, type Asset, type SpendControlErrorCode, type SpendControls, type SpendLedgerEntry, } from "./types.js"; import type { AfterPaymentCreationHook, BeforePaymentCreationHook, OnPaymentCreationFailureHook, OnPaymentResponseHook, PaymentPolicy, x402Client, } from "@x402/core/client"; import type { Network, PaymentPayload, PaymentRequirements } from "@x402/core/types"; /** Default thresholds (as fractions of the cap) for `onApproachingLimit`. */ export const DEFAULT_APPROACHING_LIMIT_THRESHOLDS: readonly number[] = [0.8, 0.95]; /** * Hard ceiling on `PaymentRequirements.maxTimeoutSeconds`, independent of * whatever value the resource server sends. `maxTimeoutSeconds` feeds * directly into the EIP-3009 `validBefore` the wallet signs, so an * unbounded server-supplied value could grant an authorization an * arbitrarily long redemption window. */ export const MAX_PAYMENT_TIMEOUT_SECONDS = 3600; const STORE_ASSET_MUTEX = new WeakMap<object, Map<string, Promise<unknown>>>(); const STORE_PROVISIONAL_ATOMIC = new WeakMap<object, Map<string, bigint>>(); const APPLIED_MARKER = Symbol.for("@coinbase/cdp-sdk/x402/spend-controls/applied"); const RECORDED_ENTRY = Symbol("@coinbase/cdp-sdk/x402/spend-controls/recorded-entry"); /** * Symbol-keyed property on a client that carries its settlement-aware confirm/rollback registry. */ export const SPEND_CONTROLS_REGISTRY = Symbol.for("@coinbase/cdp-sdk/x402/spend-controls/registry"); type PendingPayment = { entry: SpendLedgerEntry; notifyKey: string; reqAsset: string; notifiedThresholds: Set<number>; }; /** * Settlement-aware finalization handlers attached to a client by {@link applySpendControls}. */ export interface SpendControlsRegistry { /** * Mark a previously-created payment as settled on-chain. * * @param paymentPayload - The payment payload to confirm. */ confirm(paymentPayload: PaymentPayload): Promise<void>; /** * Undo a previously-recorded provisional spend entry. * * Only safe for a payload that was never transmitted — a sent authorization * is independently redeemable and can't be safely un-counted. Not called by * the SDK's own `onPaymentResponse` wiring; for callers with out-of-band * knowledge that a specific payload was never sent. * * @param paymentPayload - The payment payload to roll back. */ rollback(paymentPayload: PaymentPayload): Promise<void>; } /** * The active spend controls configuration after defaults are filled in. */ export type ResolvedSpendControls = Readonly<{ maxAmountPerPayment?: { atomic: bigint; asset?: string }; maxCumulativeSpend?: { atomic: bigint; asset?: string }; maxCumulativeSpendWindowMs?: number; allowedNetworks: ReadonlySet<Network>; allowedAssets: ReadonlySet<Asset>; allowedPayees: ReadonlySet<Address>; approachingLimitThresholds: ReadonlyArray<number>; onApproachingLimit?: (spent: Amount, limit: Amount) => void; tracker: SpendTracker; }>; const FILTER_FAILURE_NETWORK_NOT_ALLOWED = "network_not_allowed" satisfies SpendControlErrorCode; const FILTER_FAILURE_ASSET_NOT_ALLOWED = "asset_not_allowed" satisfies SpendControlErrorCode; const FILTER_FAILURE_PAYEE_NOT_ALLOWED = "payee_not_allowed" satisfies SpendControlErrorCode; type PolicyFilterFailure = | typeof FILTER_FAILURE_NETWORK_NOT_ALLOWED | typeof FILTER_FAILURE_ASSET_NOT_ALLOWED | typeof FILTER_FAILURE_PAYEE_NOT_ALLOWED; type FilterRule = { readonly code: PolicyFilterFailure; readonly message: string; readonly errorPriority: number; readonly check: (req: PaymentRequirements) => boolean; }; const pinGuardrailsBeforeHookLast = ( client: x402Client, guardrailsHook: BeforePaymentCreationHook, ): void => { /* * Access the internal hooks array to keep the spend-controls hook last so that * any subsequently registered hooks (e.g. balance check) run before our cap check. * This relies on x402Client's private `beforePaymentCreationHooks` array name. * If @x402/core renames that field we can no longer guarantee ordering; the * spend-control hook still fires (it is already registered), just not * necessarily last, so we warn rather than fail. The characterization test in * apply.test.ts pins this assumption and turns an upstream rename into a red * test at dependency-bump time. */ type WithHookArray = { beforePaymentCreationHooks?: BeforePaymentCreationHook[] }; const hooks = (client as unknown as WithHookArray).beforePaymentCreationHooks; if (!Array.isArray(hooks)) { // eslint-disable-next-line no-console console.warn( "[@coinbase/cdp-sdk/x402] Unable to pin the spend-control hook last: the x402Client no " + "longer exposes its `beforePaymentCreationHooks` array (likely an incompatible @x402/core " + "version). Spend controls remain active, but may not run after other before-payment hooks.", ); return; } const original = client.onBeforePaymentCreation.bind(client); (client as unknown as { onBeforePaymentCreation: unknown }).onBeforePaymentCreation = ( hook: BeforePaymentCreationHook, ): x402Client => { const idx = hooks.indexOf(guardrailsHook); if (idx === -1) return original(hook); hooks.splice(idx, 0, hook); return client; }; }; const buildAllowedSet = <T extends string>( values: T[] | undefined, normalize: (value: T) => T, ): ReadonlySet<T> => { if (!values || values.length === 0) return new Set(); return new Set(values.map(normalize)); }; const dedupeThresholds = (thresholds: number[] | undefined): number[] => { const fallback = DEFAULT_APPROACHING_LIMIT_THRESHOLDS; const source = thresholds && thresholds.length > 0 ? thresholds : fallback; return Array.from(new Set(source.filter(t => Number.isFinite(t) && t > 0 && t <= 1))).sort( (a, b) => a - b, ); }; const assetMatches = (capAsset: string | undefined, requirementAsset: string): boolean => { if (!capAsset) return true; return normalizeAsset(capAsset) === normalizeAsset(requirementAsset); }; const getStoreMutex = (store: object): Map<string, Promise<unknown>> => { const existing = STORE_ASSET_MUTEX.get(store); if (existing) return existing; const created = new Map<string, Promise<unknown>>(); STORE_ASSET_MUTEX.set(store, created); return created; }; /** * Extracts the atomic payment amount from a requirement, keyed on the * negotiated x402 version (v1 signs `maxAmountRequired`, v2 signs `amount`) * rather than field presence — a spoofed second field is never read. * * Also rejects the requirement outright (as malformed) if: * - it carries both `amount` and `maxAmountRequired` — a well-formed * single-version response never carries both; * - it fails to validate against the version-specific `@x402/core/schemas` * shape, so unexpected/missing fields are caught before signing; * - its `maxTimeoutSeconds` exceeds {@link MAX_PAYMENT_TIMEOUT_SECONDS}, * since that value is used directly to compute the signed authorization's * validity window. * * @param req - The selected payment requirement. * @param x402Version - The negotiated x402 protocol version. * @returns The atomic amount that will actually be signed. */ const parseAtomicFromRequirement = (req: PaymentRequirements, x402Version: number): bigint => { const untyped = req as unknown as { amount?: string; maxAmountRequired?: string }; const hasAmount = "amount" in untyped && untyped.amount !== undefined; const hasMaxAmountRequired = "maxAmountRequired" in untyped && untyped.maxAmountRequired !== undefined; if (hasAmount && hasMaxAmountRequired) { throw new SpendControlError( "malformed_requirement", "PaymentRequirements must not include both `amount` (v2) and `maxAmountRequired` (v1) " + "in the same object — a well-formed single-version response never carries both.", { asset: req.asset, network: req.network, payTo: req.payTo }, ); } let raw: string; let maxTimeoutSeconds: number; if (x402Version === 1) { const validated = PaymentRequirementsV1Schema.safeParse(req); if (!validated.success) { throw new SpendControlError( "malformed_requirement", `PaymentRequirements failed v1 schema validation: ${validated.error.message}`, { asset: req.asset, network: req.network, payTo: req.payTo }, ); } raw = validated.data.maxAmountRequired; maxTimeoutSeconds = validated.data.maxTimeoutSeconds; } else { const validated = PaymentRequirementsV2Schema.safeParse(req); if (!validated.success) { throw new SpendControlError( "malformed_requirement", `PaymentRequirements failed v2 schema validation: ${validated.error.message}`, { asset: req.asset, network: req.network, payTo: req.payTo }, ); } raw = validated.data.amount; maxTimeoutSeconds = validated.data.maxTimeoutSeconds; } if (maxTimeoutSeconds > MAX_PAYMENT_TIMEOUT_SECONDS) { throw new SpendControlError( "max_timeout_exceeded", `PaymentRequirements maxTimeoutSeconds ${maxTimeoutSeconds} exceeds the maximum allowed ${MAX_PAYMENT_TIMEOUT_SECONDS}s`, { attempted: String(maxTimeoutSeconds), limit: String(MAX_PAYMENT_TIMEOUT_SECONDS), asset: req.asset, network: req.network, payTo: req.payTo, }, ); } let parsed: bigint; try { parsed = BigInt(raw); } catch { throw new SpendControlError( "amount_unparseable", `PaymentRequirements amount ${JSON.stringify(raw)} is not a valid atomic integer`, { attempted: String(raw), asset: req.asset, network: req.network, payTo: req.payTo, }, ); } if (parsed < 0n) { throw new SpendControlError( "amount_unparseable", `PaymentRequirements amount ${JSON.stringify(raw)} must be a non-negative atomic integer`, { attempted: parsed.toString(), asset: req.asset, network: req.network, payTo: req.payTo, }, ); } return parsed; }; /** * Attach a {@link SpendControls} configuration to an `x402Client`. * * Can only be called once per client — a second call throws * `SpendControlError` (`code: "already_applied"`). * * @param client - The client to attach controls to. * @param controls - The spend controls configuration to apply. * @returns A frozen snapshot of the resolved configuration. */ export function applySpendControls( client: x402Client, controls: SpendControls, ): ResolvedSpendControls { const taggedClient = client as unknown as Record<symbol, unknown>; if (taggedClient[APPLIED_MARKER]) { throw new SpendControlError( "already_applied", "applySpendControls() was called more than once on the same x402Client. " + "Each client must have exactly one set of spend controls.", ); } const maxAmountPerPayment = controls.maxAmountPerPayment ? parseAmount(controls.maxAmountPerPayment.atomic, controls.maxAmountPerPayment.asset) : undefined; const maxCumulativeSpend = controls.maxCumulativeSpend ? parseAmount(controls.maxCumulativeSpend.atomic, controls.maxCumulativeSpend.asset) : undefined; const maxCumulativeSpendWindowMs = controls.maxCumulativeSpendWindow !== undefined ? parseDuration(controls.maxCumulativeSpendWindow) : undefined; if (maxCumulativeSpend && !maxCumulativeSpend.asset) { throw new SpendControlError( "amount_unparseable", "maxCumulativeSpend requires an `asset` — cross-asset atomic units cannot be summed.", { input: controls.maxCumulativeSpend as unknown }, ); } const allowedNetworks = buildAllowedSet(controls.allowedNetworks, n => normalizeNetwork(n)); const allowedAssets = buildAllowedSet(controls.allowedAssets, normalizeAsset); const rawAllowedPayees = controls.allowedPayees ?? []; const normalizedAllowedPayeesByNetwork = new Map<string, ReadonlySet<Address>>(); const thresholds = dedupeThresholds(controls.approachingLimitThresholds); const tracker = new SpendTracker({ maxLedgerEntries: controls.maxLedgerEntries, store: controls.store, }); const notifiedThresholdsByAsset = new Map<string, Set<number>>(); const getNotified = (asset: string): Set<number> => { let set = notifiedThresholdsByAsset.get(asset); if (!set) { set = new Set(); notifiedThresholdsByAsset.set(asset, set); } return set; }; /* * Dual-lookup structure for pending payments. * * `pendingByPayload` is the primary fast path keyed on object identity. * `pendingByFingerprint` is the fallback keyed on payload content — it * handles the case where a transport or wrapper clones the payload object * before passing it back through `onPaymentResponse` (e.g. JSON round-trip, * shallow spread). Without this, rollback/confirm would silently no-op on * a cloned payload, leaving provisional spend permanently over-counted. * * A fingerprint is built from the canonically serialized `accepted` * requirements and `payload` content. In production, x402 signatures always * include a nonce (EIP-3009 nonce / Solana blockhash), so two independent * payment operations for the same amount will produce distinct fingerprints. * The queue handles the rare edge case of identical fingerprints gracefully * by popping the oldest entry first. */ const pendingByPayload = new WeakMap<PaymentPayload, PendingPayment>(); const pendingByFingerprint = new Map<string, PendingPayment[]>(); const buildPayloadFingerprint = (p: PaymentPayload): string | null => { try { return JSON.stringify({ accepted: p.accepted, payload: p.payload }); } catch { return null; } }; const registerPending = (paymentPayload: PaymentPayload, pending: PendingPayment): void => { pendingByPayload.set(paymentPayload, pending); const fp = buildPayloadFingerprint(paymentPayload); if (fp === null) return; const queue = pendingByFingerprint.get(fp); if (queue) { queue.push(pending); } else { pendingByFingerprint.set(fp, [pending]); } }; const lookupPending = (paymentPayload: PaymentPayload): PendingPayment | undefined => { const byIdentity = pendingByPayload.get(paymentPayload); if (byIdentity !== undefined) return byIdentity; const fp = buildPayloadFingerprint(paymentPayload); if (fp === null) return undefined; return pendingByFingerprint.get(fp)?.[0]; }; const unregisterPending = (paymentPayload: PaymentPayload, pending: PendingPayment): void => { pendingByPayload.delete(paymentPayload); const fp = buildPayloadFingerprint(paymentPayload); if (fp === null) return; const queue = pendingByFingerprint.get(fp); if (!queue) return; const idx = queue.indexOf(pending); if (idx >= 0) queue.splice(idx, 1); if (queue.length === 0) pendingByFingerprint.delete(fp); }; const localAssetMutex = new Map<string, Promise<unknown>>(); const sharedStoreKey = controls.store as object | undefined; const localProvisionalAtomicByAsset = new Map<string, bigint>(); const getProvisionalMap = (): Map<string, bigint> => { if (!sharedStoreKey) return localProvisionalAtomicByAsset; let map = STORE_PROVISIONAL_ATOMIC.get(sharedStoreKey); if (!map) { map = new Map<string, bigint>(); STORE_PROVISIONAL_ATOMIC.set(sharedStoreKey, map); } return map; }; const withAssetLock = <T>(asset: string, fn: () => Promise<T>): Promise<T> => { const lockMap = sharedStoreKey ? getStoreMutex(sharedStoreKey) : localAssetMutex; const prev = lockMap.get(asset) ?? Promise.resolve(); const next = prev.then(fn, fn); lockMap.set( asset, next.catch(() => {}), ); return next; }; const readRecordedEntry = (ctx: object): SpendLedgerEntry | undefined => (ctx as Record<symbol, SpendLedgerEntry | undefined>)[RECORDED_ENTRY]; const writeRecordedEntry = (ctx: object, entry: SpendLedgerEntry): void => { (ctx as Record<symbol, SpendLedgerEntry>)[RECORDED_ENTRY] = entry; }; const clearRecordedEntry = (ctx: object): void => { delete (ctx as Record<symbol, SpendLedgerEntry | undefined>)[RECORDED_ENTRY]; }; const filterRules: ReadonlyArray<FilterRule> = [ { code: FILTER_FAILURE_NETWORK_NOT_ALLOWED, message: "All payment requirements were filtered out by allowedNetworks", errorPriority: 2, check: req => { if (allowedNetworks.size === 0) return true; try { return allowedNetworks.has(normalizeNetwork(req.network)); } catch { return false; } }, }, { code: FILTER_FAILURE_ASSET_NOT_ALLOWED, message: "All payment requirements were filtered out by allowedAssets", errorPriority: 0, check: req => { if (allowedAssets.size === 0) return true; return allowedAssets.has(normalizeAsset(req.asset)); }, }, { code: FILTER_FAILURE_PAYEE_NOT_ALLOWED, message: "All payment requirements were filtered out by allowedPayees", errorPriority: 1, check: req => { if (rawAllowedPayees.length === 0) return true; const normalizedPayee = normalizePayee(req.network, req.payTo); let canonicalReqNetwork: Network; try { canonicalReqNetwork = normalizeNetwork(req.network); } catch { return false; } let normalizedAllowList = normalizedAllowedPayeesByNetwork.get(canonicalReqNetwork); if (!normalizedAllowList) { normalizedAllowList = new Set( rawAllowedPayees.map(payee => normalizePayee(canonicalReqNetwork, payee)), ); normalizedAllowedPayeesByNetwork.set(canonicalReqNetwork, normalizedAllowList); } return normalizedAllowList.has(normalizedPayee); }, }, ]; const policy: PaymentPolicy = (_x402Version, paymentRequirements) => { const perOptionFailures: PolicyFilterFailure[] = []; const filtered = paymentRequirements.filter(req => { for (const rule of filterRules) { if (!rule.check(req)) { perOptionFailures.push(rule.code); return false; } } return true; }); if (filtered.length === 0 && perOptionFailures.length > 0) { const byPriority = [...filterRules].sort((a, b) => a.errorPriority - b.errorPriority); for (const { code, message } of byPriority) { if (perOptionFailures.includes(code)) { throw new SpendControlError(code, message); } } } return filtered; }; client.registerPolicy(policy); const beforeHook: BeforePaymentCreationHook = async context => { const req = context.selectedRequirements; const atomic = parseAtomicFromRequirement(req, context.paymentRequired.x402Version); const normalizedReqAsset = normalizeAsset(req.asset); if ( maxAmountPerPayment && atomic > maxAmountPerPayment.atomic && assetMatches(maxAmountPerPayment.asset, req.asset) ) { throw new SpendControlError( "per_payment_cap", `Payment amount ${atomic} exceeds per-payment cap ${maxAmountPerPayment.atomic}` + (maxAmountPerPayment.asset ? ` for asset ${maxAmountPerPayment.asset}` : ""), { attempted: atomic.toString(), limit: maxAmountPerPayment.atomic.toString(), asset: req.asset, network: req.network, payTo: req.payTo, }, ); } if (maxCumulativeSpend && assetMatches(maxCumulativeSpend.asset, req.asset)) { const lockKey = normalizedReqAsset; await withAssetLock(lockKey, async () => { const now = Date.now(); if (maxCumulativeSpendWindowMs !== undefined) { await tracker.prune(now, maxCumulativeSpendWindowMs); } const since = maxCumulativeSpendWindowMs !== undefined ? now - maxCumulativeSpendWindowMs : undefined; const total = await tracker.total({ asset: normalizedReqAsset, since }); if (total + atomic > maxCumulativeSpend.atomic) { throw new SpendControlError( "cumulative_cap", `Payment of ${atomic} would push cumulative spend to ${total + atomic}, exceeding cap ${maxCumulativeSpend.atomic}` + (maxCumulativeSpend.asset ? ` for asset ${maxCumulativeSpend.asset}` : "") + (maxCumulativeSpendWindowMs ? ` within ${maxCumulativeSpendWindowMs}ms window` : ""), { attempted: (total + atomic).toString(), limit: maxCumulativeSpend.atomic.toString(), asset: req.asset, network: req.network, payTo: req.payTo, }, ); } const entry = await tracker.record({ atomicAmount: atomic, asset: normalizedReqAsset, network: req.network, payTo: normalizePayee(req.network, req.payTo), }); writeRecordedEntry(context as unknown as object, entry); const pMap = getProvisionalMap(); pMap.set(normalizedReqAsset, (pMap.get(normalizedReqAsset) ?? 0n) + atomic); }); } return undefined; }; client.onBeforePaymentCreation(beforeHook); pinGuardrailsBeforeHookLast(client, beforeHook); const fireThresholdsForPayment = async ( req: PaymentRequirements, entry: SpendLedgerEntry, notifyKey: string, ): Promise<Set<number>> => { const fired = new Set<number>(); await withAssetLock(notifyKey, async () => { const pMap = getProvisionalMap(); const prevProv = pMap.get(notifyKey) ?? 0n; pMap.set(notifyKey, prevProv >= entry.atomicAmount ? prevProv - entry.atomicAmount : 0n); const notify = controls.onApproachingLimit; if (!maxCumulativeSpend || !notify || !assetMatches(maxCumulativeSpend.asset, req.asset)) { return; } const limit = maxCumulativeSpend.atomic; if (limit <= 0n) return; const now = Date.now(); if (maxCumulativeSpendWindowMs !== undefined) { await tracker.prune(now, maxCumulativeSpendWindowMs); } const since = maxCumulativeSpendWindowMs !== undefined ? now - maxCumulativeSpendWindowMs : undefined; const trackerTotal = await tracker.total({ asset: notifyKey, since }); const provisional = pMap.get(notifyKey) ?? 0n; const confirmedAfter = trackerTotal - provisional; const confirmedBefore = confirmedAfter - entry.atomicAmount; const notified = getNotified(notifyKey); for (const threshold of thresholds) { const scaledThreshold = BigInt(Math.round(threshold * 1_000_000)); const lhsBefore = confirmedBefore * 1_000_000n; const lhsAfterThis = confirmedAfter * 1_000_000n; const rhs = scaledThreshold * limit; const wasBelow = lhsBefore < rhs; const nowAtOrAbove = lhsAfterThis >= rhs; if (wasBelow) notified.delete(threshold); if (wasBelow && nowAtOrAbove && !notified.has(threshold)) { notified.add(threshold); fired.add(threshold); try { notify( { atomic: confirmedAfter, asset: req.asset }, { atomic: limit, asset: maxCumulativeSpend.asset ?? req.asset }, ); } catch (e) { // eslint-disable-next-line no-console console.warn("[@coinbase/cdp-sdk/x402] onApproachingLimit threw:", e); } } } }); return fired; }; const rollbackEntry = async (pending: PendingPayment): Promise<void> => { await withAssetLock(pending.notifyKey, async () => { const pMap = getProvisionalMap(); const prev = pMap.get(pending.notifyKey) ?? 0n; pMap.set( pending.notifyKey, prev >= pending.entry.atomicAmount ? prev - pending.entry.atomicAmount : 0n, ); try { await tracker.removeEntry(pending.entry); } catch (e) { // eslint-disable-next-line no-console console.warn("[@coinbase/cdp-sdk/x402] SpendTracker rollback failed:", e); } }); if (pending.notifiedThresholds.size > 0) { const notified = getNotified(pending.notifyKey); for (const t of pending.notifiedThresholds) { notified.delete(t); } } }; const afterHook: AfterPaymentCreationHook = async context => { const recordedEntry = readRecordedEntry(context as unknown as object); clearRecordedEntry(context as unknown as object); if (!recordedEntry) return; registerPending(context.paymentPayload, { entry: recordedEntry, notifyKey: normalizeAsset(context.selectedRequirements.asset), reqAsset: context.selectedRequirements.asset, notifiedThresholds: new Set(), }); }; client.onAfterPaymentCreation(afterHook); const failureHook: OnPaymentCreationFailureHook = async context => { const recordedEntry = readRecordedEntry(context as unknown as object); if (!recordedEntry) return undefined; clearRecordedEntry(context as unknown as object); const notifyKey = normalizeAsset((context.selectedRequirements as PaymentRequirements).asset); await withAssetLock(notifyKey, async () => { const pMap = getProvisionalMap(); const prev = pMap.get(notifyKey) ?? 0n; pMap.set( notifyKey, prev >= recordedEntry.atomicAmount ? prev - recordedEntry.atomicAmount : 0n, ); try { await tracker.removeEntry(recordedEntry); } catch (e) { // eslint-disable-next-line no-console console.warn("[@coinbase/cdp-sdk/x402] SpendTracker rollback failed:", e); } }); return undefined; }; client.onPaymentCreationFailure(failureHook); const registry: SpendControlsRegistry = { async confirm(paymentPayload) { const pending = lookupPending(paymentPayload); if (!pending) return; unregisterPending(paymentPayload, pending); const fired = await fireThresholdsForPayment( { asset: pending.reqAsset, network: pending.entry.network, } as PaymentRequirements, pending.entry, pending.notifyKey, ); pending.notifiedThresholds = fired; }, async rollback(paymentPayload) { const pending = lookupPending(paymentPayload); if (!pending) return; unregisterPending(paymentPayload, pending); await rollbackEntry(pending); }, }; (taggedClient as Record<symbol, unknown>)[SPEND_CONTROLS_REGISTRY] = registry; /* * Always confirm: by the time this fires the authorization is already * transmitted and independently redeemable, so a self-reported failure * isn't a trustworthy reason to release counted spend. */ const paymentResponseHook: OnPaymentResponseHook = async ctx => { await registry.confirm(ctx.paymentPayload); }; client.onPaymentResponse(paymentResponseHook); taggedClient[APPLIED_MARKER] = true; return Object.freeze({ maxAmountPerPayment, maxCumulativeSpend, maxCumulativeSpendWindowMs, allowedNetworks, allowedAssets, /* * The snapshot returns trimmed raw payee strings. Actual enforcement uses * normalizePayee() per-network at check time (see the filterRules payee check above). */ allowedPayees: new Set(rawAllowedPayees.map(p => p.trim())), approachingLimitThresholds: thresholds, onApproachingLimit: controls.onApproachingLimit, tracker, }); } /** * Look up the settlement-aware {@link SpendControlsRegistry} attached to an * `x402Client` by {@link applySpendControls}. * * @param client - The x402Client to look up the registry for. * @returns The registry, or undefined if spend controls were not applied. */ export function getSpendControlsRegistry(client: x402Client): SpendControlsRegistry | undefined { const tagged = client as unknown as Record<symbol, SpendControlsRegistry | undefined>; return tagged[SPEND_CONTROLS_REGISTRY]; }