UNPKG

@coinbase/cdp-sdk

Version:

SDK for interacting with the Coinbase Developer Platform Wallet API

276 lines (257 loc) • 8.75 kB
/* * 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 }, ); }