UNPKG

@coinbase/cdp-sdk

Version:

SDK for interacting with the Coinbase Developer Platform Wallet API

568 lines • 26.1 kB
"use strict"; /* * Wires a SpendControls configuration onto an upstream x402Client. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SPEND_CONTROLS_REGISTRY = exports.MAX_PAYMENT_TIMEOUT_SECONDS = exports.DEFAULT_APPROACHING_LIMIT_THRESHOLDS = void 0; exports.applySpendControls = applySpendControls; exports.getSpendControlsRegistry = getSpendControlsRegistry; const schemas_1 = require("@x402/core/schemas"); const normalize_js_1 = require("./normalize.js"); const spend_tracker_js_1 = require("./spend-tracker.js"); const types_js_1 = require("./types.js"); /** Default thresholds (as fractions of the cap) for `onApproachingLimit`. */ exports.DEFAULT_APPROACHING_LIMIT_THRESHOLDS = [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. */ exports.MAX_PAYMENT_TIMEOUT_SECONDS = 3600; const STORE_ASSET_MUTEX = new WeakMap(); const STORE_PROVISIONAL_ATOMIC = new WeakMap(); 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. */ exports.SPEND_CONTROLS_REGISTRY = Symbol.for("@coinbase/cdp-sdk/x402/spend-controls/registry"); const FILTER_FAILURE_NETWORK_NOT_ALLOWED = "network_not_allowed"; const FILTER_FAILURE_ASSET_NOT_ALLOWED = "asset_not_allowed"; const FILTER_FAILURE_PAYEE_NOT_ALLOWED = "payee_not_allowed"; const pinGuardrailsBeforeHookLast = (client, guardrailsHook) => { const hooks = client.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.onBeforePaymentCreation = (hook) => { const idx = hooks.indexOf(guardrailsHook); if (idx === -1) return original(hook); hooks.splice(idx, 0, hook); return client; }; }; const buildAllowedSet = (values, normalize) => { if (!values || values.length === 0) return new Set(); return new Set(values.map(normalize)); }; const dedupeThresholds = (thresholds) => { const fallback = exports.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, requirementAsset) => { if (!capAsset) return true; return (0, normalize_js_1.normalizeAsset)(capAsset) === (0, normalize_js_1.normalizeAsset)(requirementAsset); }; const getStoreMutex = (store) => { const existing = STORE_ASSET_MUTEX.get(store); if (existing) return existing; const created = new Map(); 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, x402Version) => { const untyped = req; const hasAmount = "amount" in untyped && untyped.amount !== undefined; const hasMaxAmountRequired = "maxAmountRequired" in untyped && untyped.maxAmountRequired !== undefined; if (hasAmount && hasMaxAmountRequired) { throw new types_js_1.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; let maxTimeoutSeconds; if (x402Version === 1) { const validated = schemas_1.PaymentRequirementsV1Schema.safeParse(req); if (!validated.success) { throw new types_js_1.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 = schemas_1.PaymentRequirementsV2Schema.safeParse(req); if (!validated.success) { throw new types_js_1.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 > exports.MAX_PAYMENT_TIMEOUT_SECONDS) { throw new types_js_1.SpendControlError("max_timeout_exceeded", `PaymentRequirements maxTimeoutSeconds ${maxTimeoutSeconds} exceeds the maximum allowed ${exports.MAX_PAYMENT_TIMEOUT_SECONDS}s`, { attempted: String(maxTimeoutSeconds), limit: String(exports.MAX_PAYMENT_TIMEOUT_SECONDS), asset: req.asset, network: req.network, payTo: req.payTo, }); } let parsed; try { parsed = BigInt(raw); } catch { throw new types_js_1.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 types_js_1.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. */ function applySpendControls(client, controls) { const taggedClient = client; if (taggedClient[APPLIED_MARKER]) { throw new types_js_1.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 ? (0, types_js_1.parseAmount)(controls.maxAmountPerPayment.atomic, controls.maxAmountPerPayment.asset) : undefined; const maxCumulativeSpend = controls.maxCumulativeSpend ? (0, types_js_1.parseAmount)(controls.maxCumulativeSpend.atomic, controls.maxCumulativeSpend.asset) : undefined; const maxCumulativeSpendWindowMs = controls.maxCumulativeSpendWindow !== undefined ? (0, types_js_1.parseDuration)(controls.maxCumulativeSpendWindow) : undefined; if (maxCumulativeSpend && !maxCumulativeSpend.asset) { throw new types_js_1.SpendControlError("amount_unparseable", "maxCumulativeSpend requires an `asset` — cross-asset atomic units cannot be summed.", { input: controls.maxCumulativeSpend }); } const allowedNetworks = buildAllowedSet(controls.allowedNetworks, n => (0, normalize_js_1.normalizeNetwork)(n)); const allowedAssets = buildAllowedSet(controls.allowedAssets, normalize_js_1.normalizeAsset); const rawAllowedPayees = controls.allowedPayees ?? []; const normalizedAllowedPayeesByNetwork = new Map(); const thresholds = dedupeThresholds(controls.approachingLimitThresholds); const tracker = new spend_tracker_js_1.SpendTracker({ maxLedgerEntries: controls.maxLedgerEntries, store: controls.store, }); const notifiedThresholdsByAsset = new Map(); const getNotified = (asset) => { 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(); const pendingByFingerprint = new Map(); const buildPayloadFingerprint = (p) => { try { return JSON.stringify({ accepted: p.accepted, payload: p.payload }); } catch { return null; } }; const registerPending = (paymentPayload, pending) => { 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) => { 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, pending) => { 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(); const sharedStoreKey = controls.store; const localProvisionalAtomicByAsset = new Map(); const getProvisionalMap = () => { if (!sharedStoreKey) return localProvisionalAtomicByAsset; let map = STORE_PROVISIONAL_ATOMIC.get(sharedStoreKey); if (!map) { map = new Map(); STORE_PROVISIONAL_ATOMIC.set(sharedStoreKey, map); } return map; }; const withAssetLock = (asset, fn) => { 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) => ctx[RECORDED_ENTRY]; const writeRecordedEntry = (ctx, entry) => { ctx[RECORDED_ENTRY] = entry; }; const clearRecordedEntry = (ctx) => { delete ctx[RECORDED_ENTRY]; }; const filterRules = [ { 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((0, normalize_js_1.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((0, normalize_js_1.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 = (0, normalize_js_1.normalizePayee)(req.network, req.payTo); let canonicalReqNetwork; try { canonicalReqNetwork = (0, normalize_js_1.normalizeNetwork)(req.network); } catch { return false; } let normalizedAllowList = normalizedAllowedPayeesByNetwork.get(canonicalReqNetwork); if (!normalizedAllowList) { normalizedAllowList = new Set(rawAllowedPayees.map(payee => (0, normalize_js_1.normalizePayee)(canonicalReqNetwork, payee))); normalizedAllowedPayeesByNetwork.set(canonicalReqNetwork, normalizedAllowList); } return normalizedAllowList.has(normalizedPayee); }, }, ]; const policy = (_x402Version, paymentRequirements) => { const perOptionFailures = []; 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 types_js_1.SpendControlError(code, message); } } } return filtered; }; client.registerPolicy(policy); const beforeHook = async (context) => { const req = context.selectedRequirements; const atomic = parseAtomicFromRequirement(req, context.paymentRequired.x402Version); const normalizedReqAsset = (0, normalize_js_1.normalizeAsset)(req.asset); if (maxAmountPerPayment && atomic > maxAmountPerPayment.atomic && assetMatches(maxAmountPerPayment.asset, req.asset)) { throw new types_js_1.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 types_js_1.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: (0, normalize_js_1.normalizePayee)(req.network, req.payTo), }); writeRecordedEntry(context, entry); const pMap = getProvisionalMap(); pMap.set(normalizedReqAsset, (pMap.get(normalizedReqAsset) ?? 0n) + atomic); }); } return undefined; }; client.onBeforePaymentCreation(beforeHook); pinGuardrailsBeforeHookLast(client, beforeHook); const fireThresholdsForPayment = async (req, entry, notifyKey) => { const fired = new Set(); 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 * 1000000n; const lhsAfterThis = confirmedAfter * 1000000n; 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) => { 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 = async (context) => { const recordedEntry = readRecordedEntry(context); clearRecordedEntry(context); if (!recordedEntry) return; registerPending(context.paymentPayload, { entry: recordedEntry, notifyKey: (0, normalize_js_1.normalizeAsset)(context.selectedRequirements.asset), reqAsset: context.selectedRequirements.asset, notifiedThresholds: new Set(), }); }; client.onAfterPaymentCreation(afterHook); const failureHook = async (context) => { const recordedEntry = readRecordedEntry(context); if (!recordedEntry) return undefined; clearRecordedEntry(context); const notifyKey = (0, normalize_js_1.normalizeAsset)(context.selectedRequirements.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 = { async confirm(paymentPayload) { const pending = lookupPending(paymentPayload); if (!pending) return; unregisterPending(paymentPayload, pending); const fired = await fireThresholdsForPayment({ asset: pending.reqAsset, network: pending.entry.network, }, pending.entry, pending.notifyKey); pending.notifiedThresholds = fired; }, async rollback(paymentPayload) { const pending = lookupPending(paymentPayload); if (!pending) return; unregisterPending(paymentPayload, pending); await rollbackEntry(pending); }, }; taggedClient[exports.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 = 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. */ function getSpendControlsRegistry(client) { const tagged = client; return tagged[exports.SPEND_CONTROLS_REGISTRY]; } //# sourceMappingURL=apply.js.map