/** * WalletReservation — local accounting layer for concurrent paid tool calls. * * Problem this solves: when N paid tool calls (today: the Modal sandbox * tools) run in parallel, each independently checks balance and dispatches * its x402 payment. With balance $0.20 and 6 calls × $0.04 each, all 6 see "$0.20 * available, $0.04 fits" and start; only 5 can actually settle on-chain, * the rest fail mid-flight with insufficient-funds and the user sees * partial completion with no preflight warning. * * The fix is *not* on-chain — x402 is fire-and-forget per-request, there's * no real "hold" capability. Instead this is a per-process bookkeeping * layer: * 1. Tool calls hold(amount) before paying. * 2. hold() refuses if (balance - sum(active reservations)) < amount. * 3. After payment succeeds OR fails, tool calls release(token). * 4. If the outcome is AMBIGUOUS — the signed request was dispatched and * then aborted / timed out before a response came back — the caller * marks the token ambiguous instead. The gateway may have settled the * payment on-chain, so the amount stays counted against headroom for * a grace window and is dropped on the next fresh balance fetch that * STARTED after the window closed (so the read reflects the real * on-chain state). The window is sized by the caller from its own * request timeout: the gateway may still be running the paid work when * we abort, and settlement lands when that work finishes. The cap can * only err tight, never loose. * * Single-process JS guarantees the check-and-set is atomic (no real race), * and balance is cached briefly so we don't hit the RPC for every hold. */ export interface ReservationToken { id: string; amountUsd: number; } /** * Settlement margin added on top of the caller-supplied request timeout for * an ambiguous-settlement hold. The window starts at OUR abort, not at the * gateway's settlement: if the gateway settles after the paid work finishes, * that can be up to the request timeout later, plus on-chain confirmation. * Callers pass `graceMs = timeoutMs + AMBIGUOUS_GRACE_MS`. */ export declare const AMBIGUOUS_GRACE_MS = 30000; declare class WalletReservationManager { private reserved; private ambiguous; private cachedBalance; private balanceFetchInflight; private balanceFetcher; private fetchBalance; private totalReserved; /** * Try to reserve `amountUsd`. Returns a token on success, or null if * insufficient (balance - already-reserved < amountUsd). Caller MUST * release the token after the actual payment resolves, success or fail. */ hold(amountUsd: number): Promise; /** * Release a hold. Idempotent — releasing the same token twice is a no-op. * Invalidate the balance cache so the next hold sees up-to-date state. */ release(token: ReservationToken | string | null | undefined): void; /** * Mark a hold as ambiguous: the signed payment was dispatched but the * request aborted / timed out before we saw the outcome. The money may be * gone, so keep the amount counted against headroom (see header) for * `graceMs` (default AMBIGUOUS_GRACE_MS; callers add their request * timeout). A later release() of the same token is a no-op — the ambiguous * entry outlives it. Idempotent: a second call for the same id is a no-op. */ markAmbiguous(token: ReservationToken | string | null | undefined, graceMs?: number): void; /** Force the next hold() to refetch balance from chain. */ invalidateBalance(): void; /** Snapshot of current reservation state — diagnostic / testing only. */ snapshot(): { count: number; totalUsd: number; ambiguousCount: number; ambiguousUsd: number; }; /** Testing only — reset all bookkeeping and cached balance. */ _resetForTests(fetcher?: () => Promise): void; /** Testing only — seed the balance cache so hold() never touches RPC. */ _seedBalanceForTests(value: number): void; /** Testing only — shift every ambiguous entry's expiry earlier by `ms`. */ _ageAmbiguousForTests(ms: number): void; } export declare const walletReservation: WalletReservationManager; export {};