across-pay-x402 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,7 @@
1
+ CDP_API_KEY_ID=your_cdp_api_key_id
2
+ CDP_API_KEY_SECRET=your_cdp_api_key_secret
3
+ CDP_WALLET_SECRET=your_cdp_wallet_secret
4
+ # Buyer wallet is now a CDP server wallet — no local private key needed.
5
+ # The wallet is created/retrieved automatically via cdp.evm.getOrCreateAccount().
6
+ MERCHANT_KEY=0xyour_merchant_private_key
7
+ PORT=4021
package/HANDOVER.md ADDED
@@ -0,0 +1,56 @@
1
+ # x402 Coinbase Buyer Demo Handover
2
+
3
+ This workspace exists to prototype **x402 + Across on mainnet** alongside the MPP buyer demo.
4
+
5
+ ## Goal
6
+
7
+ Mirror the current MPP demo flow with x402:
8
+
9
+ 1. Buyer requests a paid resource.
10
+ 2. If buyer lacks funds on the merchant's destination chain, Across bridges the shortfall to the buyer's own address.
11
+ 3. The standard x402 payment flow completes.
12
+ 4. The merchant sees a normal x402 payment.
13
+
14
+ ## Safety Rules
15
+
16
+ - Keep protocol-specific logic in x402 packages where possible.
17
+ - Keep Across responsible only for pre-funding and bridge orchestration.
18
+ - Bridge to the buyer's own address, never directly to the merchant.
19
+
20
+ ## Intended Scope
21
+
22
+ - Local Hono-based x402 seller on Base mainnet settings
23
+ - x402 buyer using official x402 packages
24
+ - Across pre-funding wrapper for the buyer flow
25
+ - `demo`: strict preferred-chain fast path for Base-funded demos
26
+ - `dynamic`: parallel funded-route discovery with quote-based tie-breaking
27
+ - Reporting/proof output similar to the current MPP prototype
28
+
29
+ ## Deliverables
30
+
31
+ - `src/dev/server.ts`: local x402 seller
32
+ - `src/buyer/x402-with-across.ts`: buyer wrapper
33
+ - `src/demo/run.ts`: end-to-end demo script
34
+ - `src/dev/preflight.ts`: env/config validation
35
+ - `src/buyer/across.ts`: Across helpers
36
+ - `src/buyer/balance.ts`: ERC-20 balance helpers
37
+ - `src/buyer/coinbase-wallet.ts`: Coinbase-shaped wallet adapter
38
+
39
+ ## Environment Expectations
40
+
41
+ - `CDP_API_KEY_ID`
42
+ - `CDP_API_KEY_SECRET`
43
+ - `COINBASE_AGENT_WALLET_PRIVATE_KEY` (preferred)
44
+ - `AGENT_PRIVATE_KEY` (legacy fallback)
45
+ - `MERCHANT_KEY`
46
+ - `PORT`
47
+ - local secrets should live in `.env.local` only
48
+ - optional `MODE=demo|dynamic`
49
+ - optional origin-chain / RPC overrides
50
+
51
+ ## Recommended Build Order
52
+
53
+ 1. Prove plain x402 against the local seller.
54
+ 2. Add Across pre-funding to the buyer flow.
55
+ 3. Verify direct-payment and bridge-payment paths.
56
+ 4. Compare with the MPP buyer demo and only then extract shared funding logic.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # x402 Coinbase Buyer Demo
2
+
3
+ Coinbase-first x402 buyer demo with Across pre-funding.
4
+
5
+ ## What Lives Here
6
+
7
+ - `src/buyer/` contains the x402 buyer flow and Across funding logic.
8
+ - `src/demo/` contains the runnable buyer demo entrypoint.
9
+ - `src/dev/` contains local-only helpers and the local x402 seller.
10
+
11
+ ## Wallet Surface
12
+
13
+ The demo uses a **Coinbase CDP server wallet** via `@coinbase/cdp-sdk`. The buyer's private key never leaves Coinbase infrastructure — signing happens via the CDP API.
14
+
15
+ The wallet is resolved through `src/buyer/coinbase-wallet.ts`, which calls `cdp.evm.getOrCreateAccount({ name: 'x402-buyer' })` to create or retrieve a persistent server-managed wallet.
16
+
17
+ ## Environment
18
+
19
+ - `CDP_API_KEY_ID` — CDP API key ID
20
+ - `CDP_API_KEY_SECRET` — CDP API key secret
21
+ - `CDP_WALLET_SECRET` — CDP wallet secret (required for signing)
22
+ - `MERCHANT_KEY` — merchant private key (local seller only)
23
+ - `PORT`
24
+
25
+ Create a local `.env.local` in this folder:
26
+
27
+ ```bash
28
+ cp .env.example .env.local
29
+ ```
30
+
31
+ ## Commands
32
+
33
+ ```bash
34
+ npm install
35
+ npm run preflight
36
+ npm run demo:local
37
+ npm run demo:local:strict
38
+ npm run demo:external
39
+ npm run dev:server
40
+ ```
41
+
42
+ ## Notes
43
+
44
+ - Mainnet target is Base (`eip155:8453`).
45
+ - The x402 seller requires CDP credentials for verify and settlement.
46
+ - The buyer does not reimplement x402; it funds first, then lets the x402 SDK create the payment payload and replay the request.
47
+ - `demo:local` uses the local seller by default.
48
+ - `demo:external` targets the CoinGecko x402 endpoint.
@@ -0,0 +1,74 @@
1
+ # Test Results
2
+
3
+ All x402 test runs and attempts.
4
+
5
+ ---
6
+
7
+ ### x402 Test Attempt (tooling startup failure)
8
+ - **Time:** 2026-03-25 (exact time not captured)
9
+ - **Target:** `https://pro-api.coingecko.com/api/v3/x402/simple/price?vs_currencies=usd&symbols=btc,eth,sol`
10
+ - **Mode:** dynamic
11
+ - **Force bridge:** no
12
+ - **Configured origin chain:** 42161
13
+ - **Buyer:** unknown
14
+ - **Result:** ERROR
15
+ - **Status:** N/A
16
+ - **Start balances:** N/A
17
+ - **End balances:** N/A
18
+ - **Balance delta:** N/A
19
+ - **Bridge info:** None
20
+ - **Payment response:** None
21
+ - **Explorer links:**
22
+ - none
23
+ - **Response body:** None
24
+ - **Error:** `tsx/esbuild startup failure: Cannot start service: Host version "0.27.4" does not match binary version "0.25.0"`
25
+
26
+ ---
27
+
28
+ ### x402 Crosschain Test (CoinGecko)
29
+ - **Time:** 2026-03-25 (exact time not captured)
30
+ - **Target:** `https://pro-api.coingecko.com/api/v3/x402/simple/price?vs_currencies=usd&symbols=btc,eth,sol`
31
+ - **Mode:** dynamic
32
+ - **Force bridge:** no
33
+ - **Configured origin chain:** 42161
34
+ - **Buyer:** `0x6a568A616dAB0a8F092139Dd64663772c180170b`
35
+ - **Result:** SUCCESS (via Across bridge)
36
+ - **Status:** 200 OK
37
+ - **Start balances:** Base ETH 0.010867872626366225, Base USDC 0
38
+ - **End balances:** Base ETH 0.010867872626366225, Base USDC 0.01
39
+ - **Balance delta:** Base ETH +0, Base USDC +0.01
40
+ - **Bridge info:** `{"used":true,"forced":false,"originChainId":42161,"destinationChainId":8453,"inputToken":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","outputToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","inputAmount":"23100","destinationAmount":"10000","depositTxHash":"0xc6150f176cf7b764224e44f44686de0067446bb1a59a990260c6b488ef51bc47","fillTxHash":"0x86b0eae59f7994ce3a03e9dd0d2de47c595093656c9a514b26a3ddf7736596ad"}`
41
+ - **Payment response:** `{"success":true,"transaction":"0x11ed6caece735d8b6b3f8706014eefee63d43d77460b29bffc30ec34f2c2ab6f","network":"eip155:8453","payer":"0x6a568A616dAB0a8F092139Dd64663772c180170b","errorReason":null}`
42
+ - **Explorer links:**
43
+ - buyerBase: https://basescan.org/address/0x6a568A616dAB0a8F092139Dd64663772c180170b
44
+ - bridgeDeposit: https://arbiscan.io/tx/0xc6150f176cf7b764224e44f44686de0067446bb1a59a990260c6b488ef51bc47
45
+ - acrossTransfer: https://app.across.to/transfer/0xc6150f176cf7b764224e44f44686de0067446bb1a59a990260c6b488ef51bc47
46
+ - bridgeFill: https://basescan.org/tx/0x86b0eae59f7994ce3a03e9dd0d2de47c595093656c9a514b26a3ddf7736596ad
47
+ - payment: https://basescan.org/tx/0x11ed6caece735d8b6b3f8706014eefee63d43d77460b29bffc30ec34f2c2ab6f
48
+ - **Response body:** `{"btc":{"usd":70867},"eth":{"usd":2164.35},"sol":{"usd":91.9}}`
49
+ - **Error:** None
50
+
51
+ ---
52
+
53
+ ### x402 Buyer Demo (CoinGecko)
54
+ - **Time:** 2026-03-31T14:54:58.892Z
55
+ - **Target:** https://pro-api.coingecko.com/api/v3/x402/simple/price?vs_currencies=usd&symbols=btc,eth,sol
56
+ - **Mode:** dynamic
57
+ - **Force bridge:** no
58
+ - **Configured origin chain:** auto
59
+ - **Buyer:** `0xE17725B0824E93Ab61706A506a4785A7312Cbe06`
60
+ - **Result:** SUCCESS (via Across bridge)
61
+ - **Status:** 200 OK
62
+ - **Start balances:** Base ETH 0, Base USDC 0
63
+ - **End balances:** Base ETH 0, Base USDC 0.02
64
+ - **Balance delta:** Base ETH +0, Base USDC +0.02
65
+ - **Bridge info:** `{"used":true,"forced":false,"originChainId":42161,"destinationChainId":8453,"inputToken":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","outputToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","inputAmount":"23604","destinationAmount":"10000","depositTxHash":"0xad1d552dd770efebf2e50a8fc9d70877daef2493c72829ad18218d05e0352006","fillTxHash":"0xeceac8278df0fef6444b29804ad6549b420b0bbf59a21f09cec0facc97bb1446"}`
66
+ - **Payment response:** `{"success":true,"transaction":"0x68f2496cad62ce86a6c0574bfaa3fad72c3b9fd5d080dfb5818bb8dd6e89e2e2","network":"eip155:8453","payer":"0xE17725B0824E93Ab61706A506a4785A7312Cbe06","errorReason":null}`
67
+ - **Explorer links:**
68
+ - payment: https://basescan.org/tx/0x68f2496cad62ce86a6c0574bfaa3fad72c3b9fd5d080dfb5818bb8dd6e89e2e2
69
+ - buyerBase: https://basescan.org/address/0xE17725B0824E93Ab61706A506a4785A7312Cbe06
70
+ - bridgeDeposit: https://arbiscan.io/tx/0xad1d552dd770efebf2e50a8fc9d70877daef2493c72829ad18218d05e0352006
71
+ - acrossTransfer: https://app.across.to/transfer/0xad1d552dd770efebf2e50a8fc9d70877daef2493c72829ad18218d05e0352006
72
+ - bridgeFill: https://basescan.org/tx/0xeceac8278df0fef6444b29804ad6549b420b0bbf59a21f09cec0facc97bb1446
73
+ - **Response body:** `{"btc":{"usd":66535},"eth":{"usd":2041.4},"sol":{"usd":80.95}}`
74
+ - **Error:** None
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "across-pay-x402",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Crosschain x402 payments via Across Protocol. Bridge from any chain, pay on Base.",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "dev:server": "node --env-file-if-exists=.env.local --experimental-strip-types src/dev/server.ts",
11
+ "demo:local": "MODE=dynamic node --env-file-if-exists=.env.local --experimental-strip-types src/demo/run.ts",
12
+ "demo:local:strict": "MODE=demo node --env-file-if-exists=.env.local --experimental-strip-types src/demo/run.ts",
13
+ "demo:external": "MODE=dynamic TEST_URL='https://pro-api.coingecko.com/api/v3/x402/simple/price?vs_currencies=usd&symbols=btc,eth,sol' node --env-file-if-exists=.env.local --experimental-strip-types src/demo/run.ts",
14
+ "preflight": "node --env-file-if-exists=.env.local --experimental-strip-types src/dev/preflight.ts"
15
+ },
16
+ "keywords": [],
17
+ "author": "",
18
+ "license": "ISC",
19
+ "dependencies": {
20
+ "@coinbase/x402": "^2.1.0",
21
+ "@hono/node-server": "^1.19.11",
22
+ "@types/node": "^25.5.0",
23
+ "@x402/core": "^2.8.0",
24
+ "@x402/evm": "^2.8.0",
25
+ "@x402/fetch": "^2.8.0",
26
+ "@x402/hono": "^2.8.0",
27
+ "esbuild": "^0.27.4",
28
+ "hono": "^4.12.9",
29
+ "tsx": "^4.21.0",
30
+ "typescript": "^6.0.2",
31
+ "viem": "^2.47.6"
32
+ }
33
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Across Protocol API helpers for dynamic EVM destination chains.
3
+ */
4
+
5
+ const ACROSS_API = 'https://app.across.to/api'
6
+
7
+ export const DEFAULT_RPCS: Record<number, string> = {
8
+ 1: 'https://eth.llamarpc.com',
9
+ 10: 'https://mainnet.optimism.io',
10
+ 56: 'https://bsc-dataseed.binance.org',
11
+ 137: 'https://polygon-rpc.com',
12
+ 324: 'https://mainnet.era.zksync.io',
13
+ 8453: 'https://mainnet.base.org',
14
+ 42161: 'https://arb1.arbitrum.io/rpc',
15
+ 59144: 'https://rpc.linea.build',
16
+ 534352: 'https://rpc.scroll.io',
17
+ }
18
+
19
+ export const DEFAULT_ORIGIN_CHAINS = [42161, 10, 137, 1, 8453]
20
+
21
+ export interface AcrossRoute {
22
+ originChainId: number
23
+ originToken: string
24
+ originTokenSymbol: string
25
+ destinationChainId: number
26
+ destinationToken: string
27
+ destinationTokenSymbol: string
28
+ }
29
+
30
+ export interface AcrossQuote {
31
+ inputAmount: string
32
+ expectedOutputAmount: string
33
+ minOutputAmount: string
34
+ expectedFillTime: number
35
+ approvalTxns: Array<{ chainId: number; to: string; data: string }>
36
+ swapTx: {
37
+ chainId: number
38
+ to: string
39
+ data: string
40
+ gas: string
41
+ simulationSuccess: boolean
42
+ }
43
+ fees: {
44
+ total: { amount: string; amountUsd: string }
45
+ }
46
+ id: string
47
+ }
48
+
49
+ export interface DepositStatus {
50
+ status: 'pending' | 'filled' | 'expired'
51
+ fillTx?: string
52
+ destinationChainId?: number
53
+ outputAmount?: string
54
+ }
55
+
56
+ export function parseEip155ChainId(network: string): number {
57
+ const match = network.match(/^eip155:(\d+)$/)
58
+ if (!match) {
59
+ throw new Error(`Unsupported network format: ${network}. Expected eip155:<chainId>`)
60
+ }
61
+
62
+ return Number(match[1])
63
+ }
64
+
65
+ export async function findRoutes(
66
+ destinationChainId: number,
67
+ outputToken: string,
68
+ fetchImpl: typeof globalThis.fetch = globalThis.fetch,
69
+ ): Promise<AcrossRoute[]> {
70
+ const url = new URL(`${ACROSS_API}/available-routes`)
71
+ url.searchParams.set('destinationChainId', destinationChainId.toString())
72
+
73
+ const response = await fetchImpl(url.toString())
74
+ if (!response.ok) {
75
+ return []
76
+ }
77
+
78
+ const routes = (await response.json()) as AcrossRoute[]
79
+ return routes.filter(
80
+ (route) => route.destinationToken.toLowerCase() === outputToken.toLowerCase(),
81
+ )
82
+ }
83
+
84
+ export async function findRoute(
85
+ originChainId: number,
86
+ destinationChainId: number,
87
+ outputToken: string,
88
+ fetchImpl: typeof globalThis.fetch = globalThis.fetch,
89
+ ): Promise<AcrossRoute | null> {
90
+ const routes = await findRoutes(destinationChainId, outputToken, fetchImpl)
91
+ return routes.find((route) => route.originChainId === originChainId) ?? null
92
+ }
93
+
94
+ export async function getQuote(
95
+ params: {
96
+ originChainId: number
97
+ destinationChainId: number
98
+ inputToken: string
99
+ outputToken: string
100
+ amount: string
101
+ depositor: string
102
+ recipient: string
103
+ },
104
+ fetchImpl: typeof globalThis.fetch = globalThis.fetch,
105
+ ): Promise<AcrossQuote> {
106
+ const url = new URL(`${ACROSS_API}/swap/approval`)
107
+
108
+ url.searchParams.set('originChainId', params.originChainId.toString())
109
+ url.searchParams.set('destinationChainId', params.destinationChainId.toString())
110
+ url.searchParams.set('inputToken', params.inputToken)
111
+ url.searchParams.set('outputToken', params.outputToken)
112
+ url.searchParams.set('amount', params.amount)
113
+ url.searchParams.set('depositor', params.depositor)
114
+ url.searchParams.set('recipient', params.recipient)
115
+ url.searchParams.set('tradeType', 'exactOutput')
116
+
117
+ const response = await fetchImpl(url.toString())
118
+ if (!response.ok) {
119
+ throw new Error(`Across quote failed (${response.status}): ${await response.text()}`)
120
+ }
121
+
122
+ return (await response.json()) as AcrossQuote
123
+ }
124
+
125
+ export async function waitForFill(
126
+ depositTxHash: string,
127
+ originChainId: number,
128
+ fetchImpl: typeof globalThis.fetch = globalThis.fetch,
129
+ timeoutMs = 120_000,
130
+ pollIntervalMs = 2_000,
131
+ ): Promise<DepositStatus> {
132
+ const start = Date.now()
133
+
134
+ while (Date.now() - start < timeoutMs) {
135
+ const url = new URL(`${ACROSS_API}/deposit/status`)
136
+ url.searchParams.set('originChainId', originChainId.toString())
137
+ url.searchParams.set('depositTxHash', depositTxHash)
138
+
139
+ const response = await fetchImpl(url.toString())
140
+ if (response.ok) {
141
+ const status = (await response.json()) as DepositStatus
142
+ if (status.status === 'filled') {
143
+ return status
144
+ }
145
+ if (status.status === 'expired') {
146
+ throw new Error('Across deposit expired without fill')
147
+ }
148
+ }
149
+
150
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
151
+ }
152
+
153
+ throw new Error(`Across fill timed out after ${timeoutMs / 1000}s`)
154
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Generic ERC-20 balance helpers for x402 EVM flows.
3
+ */
4
+ import { createPublicClient, erc20Abi, http, type Address } from 'viem'
5
+ import { DEFAULT_RPCS, type AcrossRoute } from './across.ts'
6
+
7
+ function publicRpc(chainId: number): string {
8
+ return DEFAULT_RPCS[chainId] ?? `https://lb.drpc.org/ogrpc?network=${chainId}`
9
+ }
10
+
11
+ export function getRpc(chainId: number, rpcs: Record<number, string> = {}): string {
12
+ return rpcs[chainId] ?? publicRpc(chainId)
13
+ }
14
+
15
+ export async function getErc20Balance(
16
+ address: string,
17
+ token: string,
18
+ rpcUrl: string,
19
+ ): Promise<bigint> {
20
+ try {
21
+ const client = createPublicClient({ transport: http(rpcUrl) })
22
+ return await client.readContract({
23
+ address: token as Address,
24
+ abi: erc20Abi,
25
+ functionName: 'balanceOf',
26
+ args: [address as Address],
27
+ })
28
+ } catch (error) {
29
+ console.warn(`[balance] Failed to read balance for token ${token}: ${String(error)}`)
30
+ return 0n
31
+ }
32
+ }
33
+
34
+ export interface FundedRouteBalance {
35
+ route: AcrossRoute
36
+ balance: bigint
37
+ }
38
+
39
+ export async function getFundedRouteBalances(
40
+ address: string,
41
+ routes: AcrossRoute[],
42
+ rpcs: Record<number, string> = {},
43
+ ): Promise<FundedRouteBalance[]> {
44
+ const results = await Promise.allSettled(
45
+ routes.map(async (route) => ({
46
+ route,
47
+ balance: await getErc20Balance(address, route.originToken, getRpc(route.originChainId, rpcs)),
48
+ })),
49
+ )
50
+
51
+ return results
52
+ .filter(
53
+ (result): result is PromiseFulfilledResult<FundedRouteBalance> =>
54
+ result.status === 'fulfilled' && result.value.balance > 0n,
55
+ )
56
+ .map((result) => result.value)
57
+ }
@@ -0,0 +1,26 @@
1
+ import { CdpClient } from '@coinbase/cdp-sdk'
2
+ import { toAccount } from 'viem/accounts'
3
+
4
+ export interface CoinbaseDemoWallet {
5
+ provider: 'coinbase-cdp'
6
+ source: 'CDP server wallet'
7
+ account: ReturnType<typeof toAccount>
8
+ }
9
+
10
+ /**
11
+ * Creates or retrieves a CDP server-managed wallet for the x402 buyer demo.
12
+ *
13
+ * The private key never leaves Coinbase infrastructure. Signing happens via
14
+ * the CDP API using CDP_API_KEY_ID, CDP_API_KEY_SECRET, and CDP_WALLET_SECRET.
15
+ */
16
+ export async function resolveCoinbaseDemoWallet(): Promise<CoinbaseDemoWallet> {
17
+ const cdp = new CdpClient()
18
+ const cdpAccount = await cdp.evm.getOrCreateAccount({ name: 'x402-buyer' })
19
+ const account = toAccount(cdpAccount)
20
+
21
+ return {
22
+ provider: 'coinbase-cdp',
23
+ source: 'CDP server wallet',
24
+ account,
25
+ }
26
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Wraps the x402 buyer flow with Across pre-funding on the selected EVM network.
3
+ *
4
+ * The x402 SDK remains responsible for protocol parsing, payload creation, retry,
5
+ * verification, and settlement. Across only ensures the buyer has funds on the
6
+ * destination chain before the x402 payment payload is created.
7
+ */
8
+ import { x402Client, x402HTTPClient } from '@x402/core/client'
9
+ import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm'
10
+ import { wrapFetchWithPayment } from '@x402/fetch'
11
+ import type { Account } from 'viem'
12
+ import { createPublicClient, createWalletClient, http } from 'viem'
13
+
14
+ import {
15
+ DEFAULT_ORIGIN_CHAINS,
16
+ DEFAULT_RPCS,
17
+ findRoutes,
18
+ getQuote,
19
+ parseEip155ChainId,
20
+ type AcrossQuote,
21
+ type AcrossRoute,
22
+ waitForFill,
23
+ } from './across.ts'
24
+ import {
25
+ getErc20Balance,
26
+ getFundedRouteBalances,
27
+ getRpc,
28
+ type FundedRouteBalance,
29
+ } from './balance.ts'
30
+
31
+ function toSchemeRpcConfig(rpcs: Record<number, string>) {
32
+ return Object.fromEntries(
33
+ Object.entries(rpcs).map(([chainId, rpcUrl]) => [Number(chainId), { rpcUrl }]),
34
+ )
35
+ }
36
+
37
+ function compareFundedBalances(a: FundedRouteBalance, b: FundedRouteBalance): number {
38
+ if (a.balance === b.balance) return a.route.originChainId - b.route.originChainId
39
+ return a.balance > b.balance ? -1 : 1
40
+ }
41
+
42
+ interface QuotedFundedRoute extends FundedRouteBalance {
43
+ quote: AcrossQuote
44
+ feeUsd: number
45
+ }
46
+
47
+ function compareQuotedRoutes(a: QuotedFundedRoute, b: QuotedFundedRoute): number {
48
+ if (a.balance !== b.balance) return a.balance > b.balance ? -1 : 1
49
+ if (a.feeUsd !== b.feeUsd) return a.feeUsd - b.feeUsd
50
+ return a.route.originChainId - b.route.originChainId
51
+ }
52
+
53
+ function isQuotedCandidate(
54
+ result: PromiseSettledResult<QuotedFundedRoute | null>,
55
+ ): result is PromiseFulfilledResult<QuotedFundedRoute> {
56
+ return result.status === 'fulfilled' && result.value !== null
57
+ }
58
+
59
+ function feeUsd(amountUsd: string): number {
60
+ const parsed = Number(amountUsd)
61
+ return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY
62
+ }
63
+
64
+ export interface BridgeInfo {
65
+ used: boolean
66
+ forced?: boolean
67
+ originChainId?: number
68
+ destinationChainId?: number
69
+ inputToken?: string
70
+ outputToken?: string
71
+ inputAmount?: string
72
+ destinationAmount?: string
73
+ depositTxHash?: `0x${string}`
74
+ fillTxHash?: string
75
+ }
76
+
77
+ export interface AcrossConfig {
78
+ preferredOriginChainId?: number
79
+ preferredOriginChainOnly?: boolean
80
+ originChainIds?: number[]
81
+ gasBuffer?: bigint
82
+ forceBridge?: boolean
83
+ rpcs?: Record<number, string>
84
+ }
85
+
86
+ export function createX402WithAcross(config: {
87
+ account: Account
88
+ across?: AcrossConfig
89
+ polyfill?: boolean
90
+ }) {
91
+ const rawFetch = globalThis.fetch
92
+ const account = config.account
93
+ const signer = toClientEvmSigner(account as Parameters<typeof toClientEvmSigner>[0])
94
+ const rpcs = { ...DEFAULT_RPCS, ...config.across?.rpcs }
95
+ const originChainIds = config.across?.originChainIds ?? DEFAULT_ORIGIN_CHAINS
96
+ const preferredOriginChain = config.across?.preferredOriginChainId
97
+ const preferredOriginChainOnly = config.across?.preferredOriginChainOnly ?? false
98
+ const gasBuffer = config.across?.gasBuffer ?? 0n
99
+ const forceBridge = config.across?.forceBridge ?? false
100
+
101
+ console.log(`[x402+across] Buyer: ${account.address}`)
102
+ if (preferredOriginChain) {
103
+ const mode = preferredOriginChainOnly
104
+ ? 'strict preferred-chain mode'
105
+ : 'preferred-chain fast path with dynamic fallback'
106
+ console.log(`[x402+across] Origin selection: chain ${preferredOriginChain} (${mode})`)
107
+ } else {
108
+ console.log('[x402+across] Dynamic chain discovery enabled')
109
+ }
110
+ if (forceBridge) {
111
+ console.log('[x402+across] Force-bridge mode enabled for crosschain testing')
112
+ }
113
+
114
+ const bridgeInfo: BridgeInfo = { used: false }
115
+
116
+ const client = new x402Client()
117
+ .register('eip155:*', new ExactEvmScheme(signer, toSchemeRpcConfig(rpcs)))
118
+ .onBeforePaymentCreation(async ({ selectedRequirements }) => {
119
+ if (!selectedRequirements.network.startsWith('eip155:')) {
120
+ return
121
+ }
122
+
123
+ const destinationChainId = parseEip155ChainId(selectedRequirements.network)
124
+ const destinationToken = selectedRequirements.asset
125
+ const destinationAmount = BigInt(selectedRequirements.amount)
126
+ const destinationRpc = getRpc(destinationChainId, rpcs)
127
+
128
+ console.log(`\n[x402+across] Payment requirements:`)
129
+ console.log(` Network: ${selectedRequirements.network}`)
130
+ console.log(` Asset: ${destinationToken}`)
131
+ console.log(` Amount: ${selectedRequirements.amount}`)
132
+
133
+ const destinationBalance = await getErc20Balance(
134
+ account.address,
135
+ destinationToken,
136
+ destinationRpc,
137
+ )
138
+
139
+ bridgeInfo.used = false
140
+ bridgeInfo.forced = forceBridge
141
+ bridgeInfo.originChainId = undefined
142
+ bridgeInfo.destinationChainId = destinationChainId
143
+ bridgeInfo.inputToken = undefined
144
+ bridgeInfo.outputToken = destinationToken
145
+ bridgeInfo.inputAmount = undefined
146
+ bridgeInfo.destinationAmount = selectedRequirements.amount
147
+ bridgeInfo.depositTxHash = undefined
148
+ bridgeInfo.fillTxHash = undefined
149
+
150
+ console.log(` Destination balance: ${destinationBalance}`)
151
+
152
+ if (!forceBridge && destinationBalance >= destinationAmount) {
153
+ console.log(' [skip bridge] Sufficient balance on destination chain')
154
+ return
155
+ }
156
+
157
+ const shortfall = forceBridge
158
+ ? destinationAmount + gasBuffer
159
+ : destinationAmount - destinationBalance + gasBuffer
160
+ const reason = forceBridge ? 'forced bridge for test flow' : 'includes gas buffer'
161
+ console.log(` [bridge needed] Shortfall: ${shortfall} (${reason})`)
162
+
163
+ const routes = await findRoutes(destinationChainId, destinationToken, rawFetch)
164
+ const candidateRoutes = routes.filter(
165
+ (route) =>
166
+ route.originChainId !== destinationChainId &&
167
+ originChainIds.includes(route.originChainId),
168
+ )
169
+ const originChains = [...new Set(candidateRoutes.map((route) => route.originChainId))]
170
+
171
+ console.log(
172
+ ` [route discovery] Found ${candidateRoutes.length} route(s) from ${
173
+ originChains.length > 0 ? originChains.join(', ') : 'none'
174
+ }`,
175
+ )
176
+
177
+ let bestRoute: AcrossRoute | null = null
178
+ let bestOrigin = 0
179
+ let originBalance = 0n
180
+ let bestQuote: AcrossQuote | null = null
181
+
182
+ if (preferredOriginChain) {
183
+ const preferredRoute =
184
+ candidateRoutes.find((route) => route.originChainId === preferredOriginChain) ?? null
185
+
186
+ if (preferredRoute) {
187
+ const balance = await getErc20Balance(
188
+ account.address,
189
+ preferredRoute.originToken,
190
+ getRpc(preferredOriginChain, rpcs),
191
+ )
192
+
193
+ console.log(` [fast path] Chain ${preferredOriginChain}: ${balance}`)
194
+
195
+ if (balance > 0n) {
196
+ bestRoute = preferredRoute
197
+ bestOrigin = preferredOriginChain
198
+ originBalance = balance
199
+ console.log(` [fast path hit] Using preferred chain ${preferredOriginChain}`)
200
+ } else if (preferredOriginChainOnly) {
201
+ throw new Error(`Preferred origin chain ${preferredOriginChain} has no funded route`)
202
+ } else {
203
+ console.log(' [fast path miss] No funds on preferred chain, scanning others')
204
+ }
205
+ } else if (preferredOriginChainOnly) {
206
+ throw new Error(
207
+ `No Across route from preferred chain ${preferredOriginChain} to chain ${destinationChainId}`,
208
+ )
209
+ } else {
210
+ console.log(' [fast path miss] No route on preferred chain, scanning others')
211
+ }
212
+ }
213
+
214
+ if (!bestRoute) {
215
+ const scanStart = Date.now()
216
+ const funded = (
217
+ await getFundedRouteBalances(account.address, candidateRoutes, rpcs)
218
+ ).sort(compareFundedBalances)
219
+
220
+ console.log(
221
+ ` [parallel scan] Done in ${Date.now() - scanStart}ms with ${funded.length} funded chain(s)`,
222
+ )
223
+
224
+ if (funded.length === 1) {
225
+ bestRoute = funded[0].route
226
+ bestOrigin = funded[0].route.originChainId
227
+ originBalance = funded[0].balance
228
+ console.log(` [route selection] Using only funded candidate on chain ${bestOrigin}`)
229
+ } else if (funded.length > 1) {
230
+ const quoteStart = Date.now()
231
+ const quotedCandidates = (
232
+ await Promise.allSettled(
233
+ funded.map(async (candidate): Promise<QuotedFundedRoute | null> => {
234
+ const quote = await getQuote(
235
+ {
236
+ originChainId: candidate.route.originChainId,
237
+ destinationChainId,
238
+ inputToken: candidate.route.originToken,
239
+ outputToken: destinationToken,
240
+ amount: shortfall.toString(),
241
+ depositor: account.address,
242
+ recipient: account.address,
243
+ },
244
+ rawFetch,
245
+ )
246
+
247
+ return BigInt(quote.inputAmount) <= candidate.balance
248
+ ? { ...candidate, quote, feeUsd: feeUsd(quote.fees.total.amountUsd) }
249
+ : null
250
+ }),
251
+ )
252
+ )
253
+ .filter(isQuotedCandidate)
254
+ .map((result) => result.value)
255
+ .sort(compareQuotedRoutes)
256
+
257
+ console.log(
258
+ ` [route selection] Quoted ${quotedCandidates.length}/${funded.length} candidate(s) in ${Date.now() - quoteStart}ms`,
259
+ )
260
+
261
+ if (quotedCandidates.length > 0) {
262
+ bestRoute = quotedCandidates[0].route
263
+ bestOrigin = quotedCandidates[0].route.originChainId
264
+ originBalance = quotedCandidates[0].balance
265
+ bestQuote = quotedCandidates[0].quote
266
+ }
267
+ }
268
+ }
269
+
270
+ if (!bestRoute) {
271
+ throw new Error(
272
+ `No Across origin route with sufficient funds found for ${selectedRequirements.network} ${destinationToken}`,
273
+ )
274
+ }
275
+
276
+ const quote =
277
+ bestQuote ??
278
+ (await getQuote(
279
+ {
280
+ originChainId: bestOrigin,
281
+ destinationChainId,
282
+ inputToken: bestRoute.originToken,
283
+ outputToken: destinationToken,
284
+ amount: shortfall.toString(),
285
+ depositor: account.address,
286
+ recipient: account.address,
287
+ },
288
+ rawFetch,
289
+ ))
290
+
291
+ const inputNeeded = BigInt(quote.inputAmount)
292
+ if (originBalance < inputNeeded) {
293
+ throw new Error(
294
+ `Insufficient origin balance. Need ${inputNeeded} on chain ${bestOrigin}, have ${originBalance}`,
295
+ )
296
+ }
297
+
298
+ const originRpc = getRpc(bestOrigin, rpcs)
299
+ const originWallet = createWalletClient({
300
+ account,
301
+ transport: http(originRpc),
302
+ })
303
+ const originPublic = createPublicClient({
304
+ transport: http(originRpc),
305
+ })
306
+
307
+ for (const approval of quote.approvalTxns ?? []) {
308
+ console.log('[across] Sending approval tx...')
309
+ const approvalHash = await originWallet.sendTransaction({
310
+ chain: undefined,
311
+ to: approval.to as `0x${string}`,
312
+ data: approval.data as `0x${string}`,
313
+ })
314
+ await originPublic.waitForTransactionReceipt({ hash: approvalHash })
315
+ }
316
+
317
+ console.log(`[across] Bridging from chain ${bestOrigin} to chain ${destinationChainId}`)
318
+ const depositTxHash = await originWallet.sendTransaction({
319
+ chain: undefined,
320
+ to: quote.swapTx.to as `0x${string}`,
321
+ data: quote.swapTx.data as `0x${string}`,
322
+ })
323
+ await originPublic.waitForTransactionReceipt({ hash: depositTxHash })
324
+
325
+ const fill = await waitForFill(depositTxHash, bestOrigin, rawFetch)
326
+
327
+ bridgeInfo.used = true
328
+ bridgeInfo.originChainId = bestOrigin
329
+ bridgeInfo.inputToken = bestRoute.originToken
330
+ bridgeInfo.inputAmount = quote.inputAmount
331
+ bridgeInfo.depositTxHash = depositTxHash
332
+ bridgeInfo.fillTxHash = fill.fillTx
333
+ })
334
+
335
+ const httpClient = new x402HTTPClient(client)
336
+ const paidFetch = wrapFetchWithPayment(rawFetch, httpClient)
337
+
338
+ if (config.polyfill ?? true) {
339
+ globalThis.fetch = paidFetch
340
+ }
341
+
342
+ return {
343
+ account,
344
+ bridgeInfo,
345
+ client,
346
+ httpClient,
347
+ fetch: paidFetch,
348
+ rawFetch,
349
+ rpcs,
350
+ }
351
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * x402 buyer demo against a local seller or a public x402 merchant.
3
+ *
4
+ * Usage:
5
+ * npm run demo:local
6
+ * npm run demo:local:strict
7
+ * npm run demo:external
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
10
+
11
+ import { decodePaymentResponseHeader } from '@x402/core/http'
12
+ import { createPublicClient, erc20Abi, formatUnits, http } from 'viem'
13
+ import { base } from 'viem/chains'
14
+
15
+ import { createX402WithAcross } from '../buyer/x402-with-across.ts'
16
+ import { resolveCoinbaseDemoWallet } from '../buyer/coinbase-wallet.ts'
17
+
18
+ const BASE_CHAIN_ID = 8453
19
+ const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
20
+ const REPORT_FILE = 'TEST-RESULTS.md'
21
+ const EXPLORERS: Record<number, string> = {
22
+ 1: 'https://etherscan.io',
23
+ 10: 'https://optimistic.etherscan.io',
24
+ 137: 'https://polygonscan.com',
25
+ 8453: 'https://basescan.org',
26
+ 42161: 'https://arbiscan.io',
27
+ }
28
+
29
+ function resolveMode(): 'demo' | 'dynamic' {
30
+ if (process.env.MODE === 'demo' || process.env.DEMO === '1') {
31
+ return 'demo'
32
+ }
33
+
34
+ return 'dynamic'
35
+ }
36
+
37
+ function readOriginChain(mode: 'demo' | 'dynamic'): number | undefined {
38
+ const raw = process.env.ORIGIN_CHAIN
39
+ if (raw) {
40
+ const parsed = Number(raw)
41
+ if (!Number.isFinite(parsed)) {
42
+ throw new Error(`Invalid ORIGIN_CHAIN: ${raw}`)
43
+ }
44
+ return parsed
45
+ }
46
+
47
+ return mode === 'demo' ? BASE_CHAIN_ID : undefined
48
+ }
49
+
50
+ function absBigInt(value: bigint): bigint {
51
+ return value < 0n ? -value : value
52
+ }
53
+
54
+ function formatSignedUnits(value: bigint, decimals: number): string {
55
+ const sign = value >= 0n ? '+' : '-'
56
+ return `${sign}${formatUnits(absBigInt(value), decimals)}`
57
+ }
58
+
59
+ function appendReport(entry: string) {
60
+ const header = '# Test Results\n\nAll x402 test runs and attempts.\n\n'
61
+ const existing = existsSync(REPORT_FILE) ? readFileSync(REPORT_FILE, 'utf-8') : ''
62
+ const separator = existing.length > 0 ? '\n---\n\n' : ''
63
+ writeFileSync(REPORT_FILE, (existing || header) + separator + entry)
64
+ }
65
+
66
+ async function main() {
67
+ const wallet = await resolveCoinbaseDemoWallet()
68
+ const testUrl = process.env.TEST_URL ?? 'http://localhost:4021/api/ping'
69
+ const mode = resolveMode()
70
+ const forceBridge = process.env.FORCE_BRIDGE === '1'
71
+ const preferredOriginChainId = readOriginChain(mode)
72
+ const startTime = new Date()
73
+
74
+ const testName =
75
+ mode === 'demo'
76
+ ? 'x402 Buyer Demo (strict local mode)'
77
+ : testUrl.includes('coingecko.com')
78
+ ? 'x402 Buyer Demo (CoinGecko)'
79
+ : 'x402 Buyer Demo (dynamic local mode)'
80
+
81
+ const report: Record<string, any> = {
82
+ test: testName,
83
+ timestamp: startTime.toISOString(),
84
+ target: testUrl,
85
+ mode,
86
+ forceBridge,
87
+ configuredOriginChainId: preferredOriginChainId ?? null,
88
+ buyer: null,
89
+ status: null,
90
+ statusText: null,
91
+ result: 'ERROR',
92
+ error: null,
93
+ responseBody: null,
94
+ paymentResponse: null,
95
+ bridgeInfo: null,
96
+ txLinks: {} as Record<string, string>,
97
+ }
98
+
99
+ const baseClient = createPublicClient({ chain: base, transport: http('https://mainnet.base.org') })
100
+ const { account, bridgeInfo, fetch } = createX402WithAcross({
101
+ account: wallet.account,
102
+ polyfill: false,
103
+ across:
104
+ mode === 'demo' || forceBridge || preferredOriginChainId !== undefined
105
+ ? {
106
+ preferredOriginChainId,
107
+ preferredOriginChainOnly: mode === 'demo',
108
+ forceBridge,
109
+ }
110
+ : undefined,
111
+ })
112
+
113
+ report.buyer = account.address
114
+ report.walletSource = wallet.source
115
+
116
+ const startEth = await baseClient.getBalance({ address: account.address })
117
+ const startUsdc = await baseClient.readContract({
118
+ address: BASE_USDC,
119
+ abi: erc20Abi,
120
+ functionName: 'balanceOf',
121
+ args: [account.address],
122
+ })
123
+
124
+ console.log('\n=== x402 Buyer Demo ===')
125
+ console.log(`Wallet source: ${wallet.source}`)
126
+ console.log(`Target: ${testUrl}`)
127
+ console.log(`Mode: ${mode === 'demo' ? 'demo (strict Base fast path)' : 'dynamic (parallel discovery)'}`)
128
+ console.log(`Force: ${forceBridge ? 'bridge enabled' : 'normal direct-if-funded flow'}`)
129
+ console.log(`Origin: ${preferredOriginChainId ?? 'auto'}`)
130
+ console.log(`Buyer: ${account.address}`)
131
+ console.log(`Base ETH: ${formatUnits(startEth, 18)}`)
132
+ console.log(`Base USDC: ${formatUnits(startUsdc, 6)}`)
133
+
134
+ report.startBalances = {
135
+ baseEth: formatUnits(startEth, 18),
136
+ baseUsdc: formatUnits(startUsdc, 6),
137
+ }
138
+
139
+ try {
140
+ const response = await fetch(testUrl)
141
+ const body = await response.text()
142
+ const endEth = await baseClient.getBalance({ address: account.address })
143
+ const endUsdc = await baseClient.readContract({
144
+ address: BASE_USDC,
145
+ abi: erc20Abi,
146
+ functionName: 'balanceOf',
147
+ args: [account.address],
148
+ })
149
+
150
+ const ethDelta = endEth - startEth
151
+ const usdcDelta = endUsdc - startUsdc
152
+
153
+ report.status = response.status
154
+ report.statusText = response.statusText
155
+ report.responseBody = body.length > 2000 ? `${body.slice(0, 2000)}...` : body
156
+ report.endBalances = {
157
+ baseEth: formatUnits(endEth, 18),
158
+ baseUsdc: formatUnits(endUsdc, 6),
159
+ }
160
+ report.balanceDelta = {
161
+ baseEth: formatSignedUnits(ethDelta, 18),
162
+ baseUsdc: formatSignedUnits(usdcDelta, 6),
163
+ }
164
+
165
+ console.log(`\nStatus: ${response.status} ${response.statusText}`)
166
+
167
+ const paymentResponseHeader = response.headers.get('PAYMENT-RESPONSE')
168
+ if (paymentResponseHeader) {
169
+ const paymentResponse = decodePaymentResponseHeader(paymentResponseHeader)
170
+ report.paymentResponse = paymentResponse
171
+ console.log('\nPayment Response:')
172
+ console.log(JSON.stringify(paymentResponse, null, 2))
173
+
174
+ if (paymentResponse.transaction) {
175
+ report.txLinks.payment = `${EXPLORERS[BASE_CHAIN_ID]}/tx/${paymentResponse.transaction}`
176
+ }
177
+ }
178
+
179
+ report.bridgeInfo = bridgeInfo
180
+
181
+ console.log('\nBridge Info:')
182
+ console.log(JSON.stringify(bridgeInfo, null, 2))
183
+
184
+ console.log('\nBalances:')
185
+ console.log(` End Base ETH: ${formatUnits(endEth, 18)} (delta ${formatSignedUnits(ethDelta, 18)})`)
186
+ console.log(` End Base USDC: ${formatUnits(endUsdc, 6)} (delta ${formatSignedUnits(usdcDelta, 6)})`)
187
+
188
+ console.log('\nExplorer Links:')
189
+ report.txLinks.buyerBase = `${EXPLORERS[BASE_CHAIN_ID]}/address/${account.address}`
190
+ console.log(` Buyer: ${report.txLinks.buyerBase}`)
191
+
192
+ if (bridgeInfo.depositTxHash) {
193
+ const originChainId = bridgeInfo.originChainId ?? preferredOriginChainId ?? null
194
+ const originExplorer = originChainId ? EXPLORERS[originChainId] : null
195
+ if (originExplorer) {
196
+ report.txLinks.bridgeDeposit = `${originExplorer}/tx/${bridgeInfo.depositTxHash}`
197
+ console.log(` Bridge deposit: ${report.txLinks.bridgeDeposit}`)
198
+ }
199
+ report.txLinks.acrossTransfer = `https://app.across.to/transfer/${bridgeInfo.depositTxHash}`
200
+ console.log(` Across transfer: ${report.txLinks.acrossTransfer}`)
201
+ }
202
+
203
+ if (bridgeInfo.fillTxHash) {
204
+ report.txLinks.bridgeFill = `${EXPLORERS[BASE_CHAIN_ID]}/tx/${bridgeInfo.fillTxHash}`
205
+ console.log(` Bridge fill: ${report.txLinks.bridgeFill}`)
206
+ }
207
+
208
+ console.log('\nResponse Body:')
209
+ console.log(body)
210
+
211
+ report.result = response.ok
212
+ ? bridgeInfo.used
213
+ ? 'SUCCESS (via Across bridge)'
214
+ : 'SUCCESS (direct x402 payment)'
215
+ : 'FAILED'
216
+ } catch (error) {
217
+ report.error = error instanceof Error ? error.stack ?? error.message : String(error)
218
+ throw error
219
+ } finally {
220
+ const txLinksSection =
221
+ Object.keys(report.txLinks).length > 0
222
+ ? Object.entries(report.txLinks)
223
+ .map(([key, value]) => `- ${key}: ${value}`)
224
+ .join('\n')
225
+ : '- none'
226
+
227
+ const entry = `### ${report.test}
228
+ - **Time:** ${report.timestamp}
229
+ - **Target:** ${report.target}
230
+ - **Mode:** ${report.mode}
231
+ - **Force bridge:** ${report.forceBridge ? 'yes' : 'no'}
232
+ - **Configured origin chain:** ${report.configuredOriginChainId ?? 'auto'}
233
+ - **Buyer:** \`${report.buyer ?? 'unknown'}\`
234
+ - **Result:** ${report.result}
235
+ - **Status:** ${report.status ?? 'N/A'} ${report.statusText ?? ''}
236
+ - **Start balances:** Base ETH ${report.startBalances?.baseEth ?? 'N/A'}, Base USDC ${report.startBalances?.baseUsdc ?? 'N/A'}
237
+ - **End balances:** Base ETH ${report.endBalances?.baseEth ?? 'N/A'}, Base USDC ${report.endBalances?.baseUsdc ?? 'N/A'}
238
+ - **Balance delta:** Base ETH ${report.balanceDelta?.baseEth ?? 'N/A'}, Base USDC ${report.balanceDelta?.baseUsdc ?? 'N/A'}
239
+ - **Bridge info:** ${report.bridgeInfo ? `\`${JSON.stringify(report.bridgeInfo)}\`` : 'None'}
240
+ - **Payment response:** ${report.paymentResponse ? `\`${JSON.stringify(report.paymentResponse)}\`` : 'None'}
241
+ - **Explorer links:**
242
+ ${txLinksSection}
243
+ - **Response body:** ${report.responseBody ? `\`${report.responseBody.replaceAll('`', "'")}\`` : 'None'}
244
+ - **Error:** ${report.error ? `\`${String(report.error).replaceAll('`', "'")}\`` : 'None'}
245
+ `
246
+
247
+ appendReport(entry)
248
+ console.log(`\nResults appended to ${REPORT_FILE}`)
249
+ }
250
+ }
251
+
252
+ main().catch((error) => {
253
+ console.error(error)
254
+ process.exit(1)
255
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Mainnet readiness checks for the x402 + Across prototype.
3
+ */
4
+ import { HTTPFacilitatorClient } from '@x402/core/server'
5
+ import { createFacilitatorConfig } from '@coinbase/x402'
6
+ import { createPublicClient, erc20Abi, formatUnits, http } from 'viem'
7
+ import { base } from 'viem/chains'
8
+ import { resolveCoinbaseDemoWallet } from '../buyer/coinbase-wallet.ts'
9
+
10
+ const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
11
+
12
+ function log(name: string, status: 'OK' | 'WARN' | 'FAIL', detail: string) {
13
+ console.log(`${status.padEnd(4)} ${name.padEnd(18)} ${detail}`)
14
+ }
15
+
16
+ async function main() {
17
+ const cdpApiKeyId = process.env.CDP_API_KEY_ID
18
+ const cdpApiKeySecret = process.env.CDP_API_KEY_SECRET
19
+ const cdpWalletSecret = process.env.CDP_WALLET_SECRET
20
+
21
+ if (cdpApiKeyId && cdpApiKeySecret) {
22
+ log('CDP creds', 'OK', 'CDP_API_KEY_ID / CDP_API_KEY_SECRET present')
23
+ } else {
24
+ log('CDP creds', 'FAIL', 'Missing CDP_API_KEY_ID or CDP_API_KEY_SECRET')
25
+ }
26
+
27
+ if (cdpWalletSecret) {
28
+ log('CDP wallet', 'OK', 'CDP_WALLET_SECRET present')
29
+ } else {
30
+ log('CDP wallet', 'FAIL', 'Missing CDP_WALLET_SECRET (required for signing)')
31
+ }
32
+
33
+ const wallet = await resolveCoinbaseDemoWallet()
34
+ const { account } = wallet
35
+ log('Agent address', 'OK', account.address)
36
+ log('Wallet source', 'OK', wallet.source)
37
+
38
+ const facilitatorClient = new HTTPFacilitatorClient(
39
+ createFacilitatorConfig(cdpApiKeyId, cdpApiKeySecret),
40
+ )
41
+
42
+ try {
43
+ const supported = await facilitatorClient.getSupported()
44
+ log(
45
+ 'Facilitator',
46
+ 'OK',
47
+ `supported kinds=${supported.kinds.length}, extensions=${supported.extensions.length}`,
48
+ )
49
+ } catch (error) {
50
+ log('Facilitator', 'FAIL', String(error))
51
+ }
52
+
53
+ const baseClient = createPublicClient({ chain: base, transport: http('https://mainnet.base.org') })
54
+
55
+ try {
56
+ const eth = await baseClient.getBalance({ address: account.address })
57
+ log('Base ETH', 'OK', formatUnits(eth, 18))
58
+ } catch (error) {
59
+ log('Base ETH', 'FAIL', String(error))
60
+ }
61
+
62
+ try {
63
+ const usdc = await baseClient.readContract({
64
+ address: BASE_USDC,
65
+ abi: erc20Abi,
66
+ functionName: 'balanceOf',
67
+ args: [account.address],
68
+ })
69
+ log('Base USDC', 'OK', formatUnits(usdc, 6))
70
+ } catch (error) {
71
+ log('Base USDC', 'FAIL', String(error))
72
+ }
73
+ }
74
+
75
+ main().catch((error) => {
76
+ console.error(error)
77
+ process.exit(1)
78
+ })
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Local x402 seller on Base mainnet using Coinbase's hosted facilitator.
3
+ *
4
+ * This is the local dev seller for the x402 buyer demo.
5
+ */
6
+ import { serve } from '@hono/node-server'
7
+ import { paymentMiddleware, x402ResourceServer } from '@x402/hono'
8
+ import { HTTPFacilitatorClient } from '@x402/core/server'
9
+ import { ExactEvmScheme } from '@x402/evm/exact/server'
10
+ import { createFacilitatorConfig } from '@coinbase/x402'
11
+ import { Hono } from 'hono'
12
+ import { privateKeyToAccount } from 'viem/accounts'
13
+
14
+ async function main() {
15
+ const merchantKey =
16
+ process.env.MERCHANT_KEY ??
17
+ '0x9593f890cb5779da1817bd921712d8446e7468f84b887a342fe5bf8795ce2f8b'
18
+ const cdpApiKeyId = process.env.CDP_API_KEY_ID
19
+ const cdpApiKeySecret = process.env.CDP_API_KEY_SECRET
20
+ const port = Number(process.env.PORT ?? 4021)
21
+
22
+ if (!cdpApiKeyId || !cdpApiKeySecret) {
23
+ throw new Error('Set CDP_API_KEY_ID and CDP_API_KEY_SECRET for x402 mainnet settlement')
24
+ }
25
+
26
+ const merchant = privateKeyToAccount(merchantKey as `0x${string}`)
27
+ const facilitatorClient = new HTTPFacilitatorClient(
28
+ createFacilitatorConfig(cdpApiKeyId, cdpApiKeySecret),
29
+ )
30
+ const resourceServer = new x402ResourceServer(facilitatorClient).register(
31
+ 'eip155:8453',
32
+ new ExactEvmScheme(),
33
+ )
34
+
35
+ await resourceServer.initialize()
36
+
37
+ const app = new Hono()
38
+
39
+ const routes = {
40
+ 'GET /api/premium': {
41
+ accepts: {
42
+ scheme: 'exact',
43
+ price: '$0.01',
44
+ network: 'eip155:8453',
45
+ payTo: merchant.address,
46
+ maxTimeoutSeconds: 300,
47
+ },
48
+ description: 'Premium x402 resource access',
49
+ mimeType: 'application/json',
50
+ },
51
+ 'GET /api/ping': {
52
+ accepts: {
53
+ scheme: 'exact',
54
+ price: '$0.15',
55
+ network: 'eip155:8453',
56
+ payTo: merchant.address,
57
+ maxTimeoutSeconds: 300,
58
+ },
59
+ description: 'Ping test',
60
+ mimeType: 'application/json',
61
+ },
62
+ } as const
63
+
64
+ app.use(paymentMiddleware(routes, resourceServer))
65
+
66
+ app.get('/', (c) =>
67
+ c.json({
68
+ status: 'ok',
69
+ service: 'across-x402-demo',
70
+ network: 'eip155:8453',
71
+ merchant: merchant.address,
72
+ }),
73
+ )
74
+
75
+ app.get('/api/premium', (c) =>
76
+ c.json({
77
+ message: 'Welcome to the premium x402 resource!',
78
+ paid: true,
79
+ timestamp: new Date().toISOString(),
80
+ }),
81
+ )
82
+
83
+ app.get('/api/ping', (c) =>
84
+ c.json({
85
+ pong: true,
86
+ timestamp: new Date().toISOString(),
87
+ }),
88
+ )
89
+
90
+ serve({ fetch: app.fetch, port }, () => {
91
+ console.log('\n=== Across x402 Demo Server ===')
92
+ console.log(`Merchant: ${merchant.address}`)
93
+ console.log('Network: eip155:8453 (Base mainnet)')
94
+ console.log('Protocol: x402 exact')
95
+ console.log('Price: $0.01 premium / $0.15 ping')
96
+ console.log(`\nListening on http://localhost:${port}`)
97
+ console.log(' GET / — health check (free)')
98
+ console.log(' GET /api/premium — $0.01 x402 exact on Base mainnet')
99
+ console.log(' GET /api/ping — $0.15 x402 exact on Base mainnet')
100
+ })
101
+ }
102
+
103
+ main().catch((error) => {
104
+ console.error(error)
105
+ process.exit(1)
106
+ })
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createX402WithAcross, type AcrossConfig, type BridgeInfo } from './buyer/x402-with-across.ts'
2
+ export { resolveCoinbaseDemoWallet, type CoinbaseDemoWallet } from './buyer/coinbase-wallet.ts'
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowImportingTsExtensions": true,
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["src/**/*.ts"]
14
+ }