diff --git a/.agent/context/20260913T000300Z-circle-x402-validity-window.md b/.agent/context/20260913T000300Z-circle-x402-validity-window.md new file mode 100644 index 0000000..437ae67 --- /dev/null +++ b/.agent/context/20260913T000300Z-circle-x402-validity-window.md @@ -0,0 +1,78 @@ +# Session Context: Circle x402 user-wallet validity window + +## Date/time + +- UTC: 2026-09-13T00:03:00Z + +## User goal + +Make the user-funded Circle Gateway x402 paid-API flow complete successfully instead of producing UNKNOWN when Circle rejects a delayed wallet authorization. + +## Original prompt/request + +The same paid-API errors continued: the user wallet signed, OneShot returned 202, no money moved, and the paid API remained UNKNOWN. Safe identifiers and provider evidence were supplied; no secrets were requested or recorded. + +## Assumptions + +- Circle Gateway verification is the authoritative pre-settlement boundary; a failed verification has no payment effect. +- The durable quote remains the source of amount, payer, recipient, asset, and network binding. + +## Plan + +1. Confirm the provider rejection reason with a read-only Circle verify call. +2. Add bounded approval slack to user-wallet signing and server validation. +3. Map an explicit seller verification refusal to FAILED_SAFE while preserving UNKNOWN for settlement ambiguity. +4. Run full repository validation before review or deployment. + +## Key decisions + +- Use a 605800-second authorization window (7 days plus the SDK buffer and a bounded 900-second human approval buffer). +- Keep seller-published durable quote data unchanged; the extended value is used only in the signed authorization and is accepted by Circle verify. +- Do not retry or settle any existing intent and do not store signatures. + +## Files/components touched + +- `packages/contracts/src/circle.ts` and `packages/contracts/src/index.ts` - shared validity-window constant. +- `apps/web/src/auth/privy-session.tsx` - sign user-wallet x402 authorizations with the bounded approval buffer. +- `packages/supplier-adapter/src/circle-x402.ts` - preserve the 15-minute validAfter tolerance, accept the bounded validBefore window, and classify explicit verification refusal as pre-submit safe. +- Focused web/supplier tests for the new window and refusal behavior. + +## Commands/checks + +- Read-only Circle `/v1/x402/verify` - returned `authorization_validity_too_short` for the supplied historical authorization. +- `pnpm.cmd test` - 82 files / 1089 tests passed. +- `pnpm.cmd test:browser` - 8/8 passed. +- `pnpm.cmd typecheck` - passed. +- `pnpm.cmd lint` - passed. +- `pnpm.cmd format:check` - passed. +- `pnpm.cmd check:generated` - passed. +- `git diff --check` - passed. + +## External-doc findings + +- Circle Gateway x402 verification requires a minimum seven-day authorization validity window; the SDK publishes a small buffer. The implementation adds a bounded human approval buffer and keeps the strict network/asset/domain checks. + +## Unresolved questions + +- PostgreSQL-gated tests were not separately run because this change is limited to web, contracts, and supplier-adapter code; full default validation passed. +- Production API and frontend deployment are still pending review/merge. + +## Git and PR state + +- Branch: `fix/circle-x402-validity-window` +- Base: `origin/develop` at `fd515729ddba8516073ac598cf588bedb9291500` +- Commit: merge `f7482378c9d5c7af30f880b54e03eb9d0e8409d1` +- Tree: `aaf5b4a6f6c7844f7f17314ae9e969d16a10b235` +- PR: #110, pending updated remote head +- CI: not run; local validation is recorded above + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Run Gate A on the exact candidate tree `aaf5b4a6f6c7844f7f17314ae9e969d16a10b235`. +2. After approval, merge/push and deploy the API image plus the web bundle; the seller image has no source change for this fix. +3. Test with one new paid-API task only after deployment; do not retry the old UNKNOWN intent blindly. diff --git a/apps/web/src/auth/privy-session.tsx b/apps/web/src/auth/privy-session.tsx index 8fe9159..1d7990f 100644 --- a/apps/web/src/auth/privy-session.tsx +++ b/apps/web/src/auth/privy-session.tsx @@ -10,7 +10,11 @@ import { import { BatchEvmScheme } from '@circle-fin/x402-batching/client'; import { encodeFunctionData, erc20Abi, defineChain } from 'viem'; import { useEffect, useRef, useState, type ReactNode } from 'react'; -import type { PaidApiQuote, SubmitPaidApiUserWalletRequest } from '@oneshot/contracts'; +import { + CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS, + type PaidApiQuote, + type SubmitPaidApiUserWalletRequest, +} from '@oneshot/contracts'; import { GatewayFundingError, @@ -144,6 +148,28 @@ function jsonSafe(value: unknown): unknown { return value; } +export function circleX402SigningRequirements(quote: PaidApiQuote) { + return { + scheme: 'exact' as const, + network: quote.network, + asset: '0x3600000000000000000000000000000000000000', + amount: quote.amount_atomic, + payTo: quote.recipient, + // The SDK's 100-second buffer is too narrow for a human wallet prompt + // plus network forwarding. This is used only inside the signed payload; + // the durable quote remains unchanged and is reconstructed server-side. + maxTimeoutSeconds: Math.max( + quote.max_timeout_seconds, + CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS, + ), + extra: { + name: 'GatewayWalletBatched', + version: '1', + verifyingContract: '0x0077777d7EBA4688BDeF3E311b846F25870A19B9', + }, + }; +} + function isPrivyEthereumWallet( value: BaseConnectedWalletType | undefined, ): value is ConnectedWallet { @@ -484,19 +510,7 @@ export function usePrivyUserWallet(): UserWalletSession { return signature as `0x${string}`; }, }; - const requirements = { - scheme: 'exact' as const, - network: quote.network, - asset: '0x3600000000000000000000000000000000000000', - amount: quote.amount_atomic, - payTo: quote.recipient, - maxTimeoutSeconds: quote.max_timeout_seconds, - extra: { - name: 'GatewayWalletBatched', - version: '1', - verifyingContract: '0x0077777d7EBA4688BDeF3E311b846F25870A19B9', - }, - }; + const requirements = circleX402SigningRequirements(quote); const partial = await new BatchEvmScheme(signer).createPaymentPayload( quote.x402_version, requirements, diff --git a/apps/web/test/privy-session.test.tsx b/apps/web/test/privy-session.test.tsx index 96b4e4b..3abafd1 100644 --- a/apps/web/test/privy-session.test.tsx +++ b/apps/web/test/privy-session.test.tsx @@ -46,7 +46,12 @@ vi.mock('@privy-io/react-auth', () => ({ useWallets: () => ({ ready: true, wallets: mocks.wallets }), })); -const { PrivyOperatorProvider, usePrivyOperatorSession, usePrivyUserWallet } = +const { + PrivyOperatorProvider, + circleX402SigningRequirements, + usePrivyOperatorSession, + usePrivyUserWallet, +} = await import('../src/auth/privy-session.js'); function ethereumWallet(walletClientType: string, address: string) { @@ -108,6 +113,21 @@ describe('usePrivyOperatorSession — native Privy login', () => { expect(mocks.getAccessToken).toHaveBeenCalledOnce(); }); + it('uses an approval buffer beyond the Gateway SDK minimum for x402 signing', () => { + expect( + circleX402SigningRequirements({ + supplier_id: 'circle-x402-v1', + resource_url: '/api/premium/dataset', + recipient: '0xa605EE031E41f04f8e193059a39A24407f83677c', + amount_atomic: '10000', + asset: 'USDC', + network: 'eip155:5042002', + x402_version: 2, + max_timeout_seconds: 604900, + }).maxTimeoutSeconds, + ).toBe(605800); + }); + it("reads only the connected wallet's Arc Testnet Gateway balance before x402 signing", async () => { const payerWallet = '0x2222222222222222222222222222222222222222'; const fetchMock = vi.fn( diff --git a/packages/contracts/src/circle.ts b/packages/contracts/src/circle.ts new file mode 100644 index 0000000..8b9ee25 --- /dev/null +++ b/packages/contracts/src/circle.ts @@ -0,0 +1,6 @@ +/** + * Circle Gateway requires at least seven days of authorization validity. + * Keep a bounded approval buffer for a human wallet prompt before OneShot + * forwards the signed authorization. + */ +export const CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS = 605_800; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2a302f9..391bf3c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from './generated/api-types.js'; +export * from './circle.js'; export * from './ids.js'; export * from './intent.js'; export * from './job.js'; diff --git a/packages/supplier-adapter/src/circle-x402.ts b/packages/supplier-adapter/src/circle-x402.ts index 865ea46..343375b 100644 --- a/packages/supplier-adapter/src/circle-x402.ts +++ b/packages/supplier-adapter/src/circle-x402.ts @@ -7,7 +7,7 @@ import type { PaymentRequirements, SettleResponse, } from '@x402/core/types'; -import { asEvmAddress } from '@oneshot/contracts'; +import { asEvmAddress, CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS } from '@oneshot/contracts'; import type { TransactionReceipt } from '@oneshot/arc-adapter'; export const ARC_X402_GATEWAY_WALLET = '0x0077777d7EBA4688BDeF3E311b846F25870A19B9'; @@ -15,6 +15,9 @@ export const ARC_X402_NETWORK = 'eip155:5042002'; export const ARC_X402_USDC = '0x3600000000000000000000000000000000000000'; const DEFAULT_MAX_AMOUNT_ATOMIC = 10_000n; const MAX_CIRCLE_X402_TIMEOUT_SECONDS = 604_900; +const MAX_CIRCLE_X402_VALIDITY_WINDOW_SECONDS = BigInt( + CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS, +); /** * Circle's browser scheme backdates validAfter by ten minutes. Keep a bounded * additional five-minute allowance for a human wallet approval before a @@ -144,7 +147,7 @@ export function parseCircleX402UserWalletPayload( validAfter < now - MAX_CIRCLE_X402_VALID_AFTER_AGE_SECONDS || validAfter > now || validBefore < now || - validBefore > now + BigInt(MAX_CIRCLE_X402_TIMEOUT_SECONDS) + validBefore > now + MAX_CIRCLE_X402_VALIDITY_WINDOW_SECONDS ) { throw new CircleX402PreSubmitError( 'x402 user-wallet payment authorization is outside its validity window', @@ -296,6 +299,11 @@ export class CircleX402UserWalletForwarder { `x402 paid response was malformed: ${cause instanceof Error ? cause.message : 'unknown error'}`, ); } + if (isPreSettlementVerificationRefusal(response.status, body)) { + throw new CircleX402PreSubmitError( + 'x402 supplier rejected the authorization before settlement', + ); + } if (!response.ok || settlement?.success !== true) { throw new CircleX402AmbiguousError( input.businessIntentId, @@ -481,6 +489,16 @@ function parseSettlement(response: Response): SettleResponse | undefined { return decoded as SettleResponse; } +function isPreSettlementVerificationRefusal(status: number, body: unknown): boolean { + return ( + status === 402 && + typeof body === 'object' && + body !== null && + !Array.isArray(body) && + (body as Record).error === 'Payment verification failed' + ); +} + async function fetchQuote( fetchFn: typeof fetch, url: string, @@ -716,6 +734,11 @@ export class CircleX402Client { `x402 paid response was malformed: ${cause instanceof Error ? cause.message : 'unknown error'}`, ); } + if (isPreSettlementVerificationRefusal(response.status, body)) { + throw new CircleX402PreSubmitError( + 'x402 supplier rejected the authorization before settlement', + ); + } if (!response.ok || settlement?.success !== true) { throw new CircleX402AmbiguousError( input.businessIntentId, diff --git a/packages/supplier-adapter/test/circle-x402.test.ts b/packages/supplier-adapter/test/circle-x402.test.ts index 094cb23..6c62375 100644 --- a/packages/supplier-adapter/test/circle-x402.test.ts +++ b/packages/supplier-adapter/test/circle-x402.test.ts @@ -5,7 +5,9 @@ import { CircleX402PreSubmitError, CircleX402UserWalletForwarder, parseCircleX402Quote, + parseCircleX402UserWalletPayload, } from '../src/circle-x402.js'; +import { CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS } from '@oneshot/contracts'; const URL = 'https://x402.example.test/api/dataset'; const PAY_TO = '0x1111111111111111111111111111111111111111'; @@ -430,4 +432,89 @@ describe('Circle Gateway x402 client', () => { ).rejects.toBeInstanceOf(CircleX402PreSubmitError); expect(fetchFn).not.toHaveBeenCalled(); }); + + it('accepts a bounded human-approval validity buffer and rejects longer windows', () => { + const payer = '0x2222222222222222222222222222222222222222'; + const quote = parseCircleX402Quote({ + url: URL, + resourceUrl: URL, + x402Version: 2, + requirements: requirements(), + }); + const now = Math.floor(Date.now() / 1000); + const payload = (validBefore: number) => ({ + x402Version: 2, + payload: { + authorization: { + from: payer, + to: PAY_TO, + value: '10000', + validAfter: String(now - 600), + validBefore: String(validBefore), + nonce: `0x${'c'.repeat(64)}`, + }, + signature: `0x${'d'.repeat(130)}`, + }, + }); + + expect(() => + parseCircleX402UserWalletPayload( + payload(now + CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS), + quote, + payer, + ), + ).not.toThrow(); + expect(() => + parseCircleX402UserWalletPayload( + payload(now + CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS + 1), + quote, + payer, + ), + ).toThrow(CircleX402PreSubmitError); + }); + + it('marks an explicit supplier verification refusal as pre-submit safe', async () => { + const payer = '0x2222222222222222222222222222222222222222'; + const quote = parseCircleX402Quote({ + url: URL, + resourceUrl: URL, + x402Version: 2, + requirements: requirements(), + }); + const fetchFn = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: 'Payment verification failed', + reason: 'authorization_validity_too_short', + }), + { status: 402, headers: { 'content-type': 'application/json' } }, + ), + ); + const forwarder = new CircleX402UserWalletForwarder({ + allowedUrl: URL, + fetchFn, + }); + + await expect( + forwarder.forward({ + businessIntentId: 'intent-x402-verification-refused', + quote, + payerAddress: payer, + paymentPayload: { + x402Version: 2, + payload: { + authorization: { + from: payer, + to: PAY_TO, + value: '10000', + validAfter: String(Math.floor(Date.now() / 1000) - 600), + validBefore: String(Math.floor(Date.now() / 1000) + 600), + nonce: `0x${'c'.repeat(64)}`, + }, + signature: `0x${'d'.repeat(130)}`, + }, + }, + }), + ).rejects.toBeInstanceOf(CircleX402PreSubmitError); + }); });