@coinbase/cdp-sdk
Version:
SDK for interacting with the Coinbase Developer Platform Wallet API
568 lines • 26.1 kB
JavaScript
"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