@coinbase/cdp-sdk
Version:
SDK for interacting with the Coinbase Developer Platform Wallet API
276 lines (257 loc) • 8.75 kB
text/typescript
/*
* Types and parsers for the SpendControls API.
*/
import type { Network } from "@x402/core/types";
/**
* An asset identifier — typically an ERC-20 contract address or a Solana SPL mint address.
*/
export type Asset = string;
/**
* A wallet address. Normalization (e.g. lower-casing EVM addresses) is handled in `./normalize.ts`.
*/
export type Address = string;
/**
* A payment amount in base units (the smallest denomination for the asset),
* optionally scoped to a specific asset.
*
* - `atomic` is a non-negative integer. Strings are accepted for convenience.
* - `asset` is optional but recommended. When set, caps and totals are scoped to that asset.
*
* @example
* ```ts
* const cap: Amount = {
* atomic: 1_000_000n,
* asset: "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
* };
* ```
*/
export type Amount = {
atomic: bigint | string;
asset?: Asset;
};
/**
* A duration expressed as either a number of milliseconds or one of the
* shorthand strings: `"500ms"`, `"30s"`, `"5m"`, `"1h"`, `"24h"`, `"7d"`.
*/
export type Duration = number | string;
/**
* Typed string constants for every {@link SpendControlErrorCode} value.
*/
export const SpendControlErrorCodes = {
PER_PAYMENT_CAP: "per_payment_cap",
CUMULATIVE_CAP: "cumulative_cap",
ALREADY_APPLIED: "already_applied",
CONFIGURATION_INVALID: "configuration_invalid",
LEDGER_CAPACITY_EXCEEDED: "ledger_capacity_exceeded",
NETWORK_NOT_ALLOWED: "network_not_allowed",
ASSET_NOT_ALLOWED: "asset_not_allowed",
PAYEE_NOT_ALLOWED: "payee_not_allowed",
AMOUNT_UNPARSEABLE: "amount_unparseable",
MALFORMED_REQUIREMENT: "malformed_requirement",
MAX_TIMEOUT_EXCEEDED: "max_timeout_exceeded",
} as const;
/**
* Error code for {@link SpendControlError}.
*/
export type SpendControlErrorCode =
(typeof SpendControlErrorCodes)[keyof typeof SpendControlErrorCodes];
/**
* Structured context attached to a {@link SpendControlError}.
*/
export type SpendControlErrorDetails = {
attempted?: string;
limit?: string;
asset?: Asset;
network?: Network | string;
payTo?: Address;
input?: unknown;
};
/**
* Thrown when a payment is blocked by the configured spend controls.
*/
export class SpendControlError extends Error {
/** - Identifies the type of violation. */
public readonly code: SpendControlErrorCode;
/** - Structured context for the error (always defined, may be empty). */
public readonly details: SpendControlErrorDetails;
/**
* Creates a SpendControlError with the given code and message.
*
* @param code - The error code identifying the violation type.
* @param message - Human-readable error message.
* @param details - Optional structured context for the error.
*/
constructor(
code: SpendControlErrorCode,
message: string,
details: SpendControlErrorDetails = {},
) {
super(message);
this.name = "SpendControlError";
this.code = code;
this.details = details;
Object.setPrototypeOf(this, SpendControlError.prototype);
}
}
/**
* A single recorded payment in the spend ledger.
*/
export type SpendLedgerEntry = {
atomicAmount: bigint;
asset: Asset;
network: Network;
payTo: Address;
at: number;
};
/**
* Storage interface for the spend ledger.
*
* **The default implementation is in-memory and process-local.** For
* production workloads where the cap must be enforced across restarts or
* replicas, implement this interface against a shared durable backend (Redis,
* Postgres, DynamoDB, etc.) and pass an instance via {@link SpendControls.store}.
*/
export interface SpendStore {
/** - Optional: return the current entry count. */
size?(): Promise<number>;
/** - Returns all entries currently held by the store. */
load(): Promise<SpendLedgerEntry[]>;
/** - Adds a single entry to the store. */
append(entry: SpendLedgerEntry): Promise<void>;
/** - Optional: drop entries older than `olderThanMs`. */
prune?(olderThanMs: number): Promise<void>;
/** - Optional: drop the oldest `n` entries. */
dropOldest?(n: number): Promise<void>;
/** - Optional: remove a specific entry by its field values. */
removeEntry?(entry: SpendLedgerEntry): Promise<void>;
}
/**
* Configuration for spend controls. All fields are optional.
*
* @example
* ```ts
* const USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
* const controls: SpendControls = {
* maxAmountPerPayment: { atomic: 10_000n, asset: USDC_BASE },
* maxCumulativeSpend: { atomic: 50_000n, asset: USDC_BASE },
* maxCumulativeSpendWindow: "24h",
* allowedNetworks: ["eip155:8453"],
* };
* ```
*/
export type SpendControls = {
/**
* Hard per-payment cap. If `asset` is set the cap only applies to payments in
* that asset — payments in any other asset are left uncapped by this field
* (atomic units are not comparable across assets with different decimals).
* Omit `asset` to cap every payment by raw atomic amount.
*/
maxAmountPerPayment?: Amount;
/**
* Rolling cumulative cap. Unlike `maxAmountPerPayment`, `asset` is required
* (enforced when spend controls are applied) because cross-asset atomic units
* cannot be summed.
*/
maxCumulativeSpend?: Amount;
maxCumulativeSpendWindow?: Duration;
allowedNetworks?: Network[];
allowedAssets?: Asset[];
allowedPayees?: Address[];
onApproachingLimit?: (spent: Amount, limit: Amount) => void;
approachingLimitThresholds?: number[];
maxLedgerEntries?: number;
store?: SpendStore;
};
const MS_UNIT_FACTORS: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60 * 1_000,
h: 60 * 60 * 1_000,
d: 24 * 60 * 60 * 1_000,
};
const DURATION_PATTERN = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i;
/**
* Converts a {@link Duration} to milliseconds.
*
* Accepts a non-negative number (already in milliseconds) or a shorthand
* string: `"500ms"`, `"30s"`, `"5m"`, `"1h"`, `"24h"`, `"7d"`.
*
* @param input - The duration value to parse.
* @returns The duration in milliseconds.
* @throws {SpendControlError} `"amount_unparseable"` if the input can't be parsed.
*/
export function parseDuration(input: Duration): number {
if (typeof input === "number") {
if (!Number.isFinite(input) || input < 0) {
throw new SpendControlError(
"amount_unparseable",
`Duration must be a non-negative finite number, received ${input}`,
{ input },
);
}
return Math.floor(input);
}
const match = DURATION_PATTERN.exec(input);
if (!match) {
throw new SpendControlError(
"amount_unparseable",
`Cannot parse duration ${JSON.stringify(input)}; expected e.g. "500ms", "30s", "5m", "1h", "24h", "7d"`,
{ input },
);
}
const [, numericPart, unit] = match;
const factor = MS_UNIT_FACTORS[unit.toLowerCase()];
return Math.floor(Number(numericPart) * factor);
}
const AMOUNT_SHORTHAND_PATTERN = /^([^:]+):(\d+)$/;
/**
* Parses an amount into `{ atomic: bigint; asset?: string }`.
*
* @param value - The amount value to parse (bigint, string, or Amount object).
* @param asset - Optional asset identifier to scope the amount.
* @returns Parsed amount with atomic bigint value and optional asset.
* @throws {SpendControlError} `"amount_unparseable"` on bad input.
*/
export function parseAmount(
value: bigint | string | Amount,
asset?: Asset,
): { atomic: bigint; asset?: string } {
if (typeof value === "bigint") {
if (value < 0n) {
throw new SpendControlError(
"amount_unparseable",
`Amount must be non-negative, got ${value}`,
{ input: value.toString() },
);
}
return { atomic: value, asset };
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "") {
throw new SpendControlError("amount_unparseable", "Amount string is empty", { input: value });
}
const shorthand = AMOUNT_SHORTHAND_PATTERN.exec(trimmed);
if (shorthand) {
const [, parsedAsset, parsedAtomic] = shorthand;
return parseAmount({ atomic: parsedAtomic, asset: parsedAsset }, asset ?? parsedAsset);
}
if (!/^\d+$/.test(trimmed)) {
throw new SpendControlError(
"amount_unparseable",
`Cannot parse amount ${JSON.stringify(value)}; expected a non-negative integer or an "<asset>:<atomic>" shorthand`,
{ input: value },
);
}
return { atomic: BigInt(trimmed), asset };
}
if (value && typeof value === "object" && "atomic" in value) {
const innerAsset = asset ?? value.asset;
return parseAmount(value.atomic as bigint | string, innerAsset);
}
throw new SpendControlError(
"amount_unparseable",
`Cannot parse amount: unsupported value of type ${typeof value}`,
{ input: value as unknown },
);
}