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 +7 -0
- package/HANDOVER.md +56 -0
- package/README.md +48 -0
- package/TEST-RESULTS.md +74 -0
- package/package.json +33 -0
- package/src/buyer/across.ts +154 -0
- package/src/buyer/balance.ts +57 -0
- package/src/buyer/coinbase-wallet.ts +26 -0
- package/src/buyer/x402-with-across.ts +351 -0
- package/src/demo/run.ts +255 -0
- package/src/dev/preflight.ts +78 -0
- package/src/dev/server.ts +106 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +14 -0
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.
|
package/TEST-RESULTS.md
ADDED
|
@@ -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
|
+
}
|
package/src/demo/run.ts
ADDED
|
@@ -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
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
|
+
}
|