Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .agent/context/20260913T000300Z-circle-x402-validity-window.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 28 additions & 14 deletions apps/web/src/auth/privy-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion apps/web/test/privy-session.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/circle.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
27 changes: 25 additions & 2 deletions packages/supplier-adapter/src/circle-x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ 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';
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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>).error === 'Payment verification failed'
);
}

async function fetchQuote(
fetchFn: typeof fetch,
url: string,
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions packages/supplier-adapter/test/circle-x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof fetch>().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);
});
});
Loading