Skip to content

Commit 507c0ca

Browse files
authored
Merge pull request #457 from nansen-ai/feat/x402-bsc-permit2-multi-asset
feat(x402): BSC multi-asset payments + Permit2 signing
2 parents 5def52a + d02572c commit 507c0ca

6 files changed

Lines changed: 401 additions & 28 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nansen-cli": minor
3+
---
4+
5+
x402 on BNB Smart Chain: support all four stablecoins the API now advertises (U, USD1, USDT, USDC) and add Permit2 payment signing. Payments route on the 402's `extra.assetTransferMethod``eip3009` keeps the existing gasless flow (U, USD1), while `permit2-exact` (USDT, USDC on BSC) signs a Permit2 `PermitWitnessTransferFrom` against the spender contract advertised in the 402. Permit2 entries are skipped with an actionable message when the wallet hasn't made the one-time `approve(Permit2, …)` for the token. Post-payment balance warnings now check the exact token paid with (per-token decimals) instead of one hardcoded token per network.

src/__tests__/x402-balance.test.js

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
*/
44

55
import { describe, it, expect } from 'vitest';
6-
import { EVM_X402_TOKENS } from '../x402.js';
7-
import { CHAIN_RPCS } from '../rpc-urls.js';
6+
import { EVM_X402_TOKENS, EVM_X402_RPCS } from '../x402.js';
87

98
describe('EVM_X402_TOKENS registry', () => {
109
it('covers Base, X Layer, and BNB Smart Chain', () => {
@@ -13,26 +12,34 @@ describe('EVM_X402_TOKENS registry', () => {
1312
);
1413
});
1514

16-
it('uses 18 decimals for BSC USDT and 6 for the others', () => {
17-
expect(EVM_X402_TOKENS['eip155:56'].decimals).toBe(18);
18-
expect(EVM_X402_TOKENS['eip155:8453'].decimals).toBe(6);
19-
expect(EVM_X402_TOKENS['eip155:196'].decimals).toBe(6);
15+
it('lists the four BSC stablecoins with eip3009-capable tokens first', () => {
16+
const symbols = EVM_X402_TOKENS['eip155:56'].map(t => t.symbol);
17+
expect(symbols).toEqual(['U', 'USD1', 'USDT', 'USDC']);
18+
});
19+
20+
it('uses 18 decimals for every BSC token and 6 elsewhere', () => {
21+
for (const t of EVM_X402_TOKENS['eip155:56']) {
22+
expect(t.decimals, t.symbol).toBe(18);
23+
}
24+
expect(EVM_X402_TOKENS['eip155:8453'][0].decimals).toBe(6);
25+
expect(EVM_X402_TOKENS['eip155:196'][0].decimals).toBe(6);
2026
});
2127

2228
it('pins the expected token contracts', () => {
23-
expect(EVM_X402_TOKENS['eip155:8453'].token)
29+
expect(EVM_X402_TOKENS['eip155:8453'][0].token)
2430
.toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'); // Base USDC
25-
expect(EVM_X402_TOKENS['eip155:56'].token)
26-
.toBe('0x55d398326f99059fF775485246999027B3197955'); // BSC USDT
31+
const bsc = Object.fromEntries(
32+
EVM_X402_TOKENS['eip155:56'].map(t => [t.symbol, t.token]),
33+
);
34+
expect(bsc.U).toBe('0xcE24439F2D9C6a2289F741120FE202248B666666');
35+
expect(bsc.USD1).toBe('0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d');
36+
expect(bsc.USDT).toBe('0x55d398326f99059fF775485246999027B3197955');
37+
expect(bsc.USDC).toBe('0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d');
2738
});
2839

2940
it('resolves an RPC URL for every network', () => {
30-
for (const [network, cfg] of Object.entries(EVM_X402_TOKENS)) {
31-
expect(cfg.rpc, `rpc for ${network}`).toMatch(/^https?:\/\//);
41+
for (const network of Object.keys(EVM_X402_TOKENS)) {
42+
expect(EVM_X402_RPCS[network], `rpc for ${network}`).toMatch(/^https?:\/\//);
3243
}
3344
});
34-
35-
it('has a BSC entry in the shared RPC registry', () => {
36-
expect(CHAIN_RPCS.bsc).toMatch(/^https?:\/\//);
37-
});
3845
});

src/__tests__/x402-permit2.test.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Tests for Permit2 (permit2-exact) x402 payment signing.
3+
*
4+
* The digest test vector was computed with an independent EIP-712
5+
* implementation (Python eth_account) over the same typed data, so it
6+
* cross-validates the nested-struct hashing end to end.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import {
11+
createEvmPaymentPayload,
12+
createPermit2ExactPayload,
13+
hashPermit2WitnessTransfer,
14+
PERMIT2_ADDRESS,
15+
} from '../x402-evm.js';
16+
import { generateEvmWallet } from '../wallet.js';
17+
18+
const REQUIREMENTS_PERMIT2 = {
19+
scheme: 'exact',
20+
network: 'eip155:56',
21+
asset: '0x1000000000000000000000000000000000000001',
22+
amount: '10000000000000000',
23+
payTo: '0x3000000000000000000000000000000000000003',
24+
maxTimeoutSeconds: 300,
25+
extra: {
26+
name: 'Test Token',
27+
version: '1',
28+
assetTransferMethod: 'permit2-exact',
29+
signerAddress: '0x4000000000000000000000000000000000000004',
30+
spenderAddress: '0x2000000000000000000000000000000000000002',
31+
},
32+
};
33+
34+
describe('hashPermit2WitnessTransfer', () => {
35+
it('matches an independently computed EIP-712 digest', () => {
36+
// Reference digest computed with Python eth_account over identical input.
37+
const digest = hashPermit2WitnessTransfer(56, {
38+
permitted: {
39+
token: '0x1000000000000000000000000000000000000001',
40+
amount: 10000000000000000n,
41+
},
42+
spender: '0x2000000000000000000000000000000000000002',
43+
nonce: 123456789n,
44+
deadline: 1750000000n,
45+
witness: {
46+
to: '0x3000000000000000000000000000000000000003',
47+
validAfter: 1749999000n,
48+
},
49+
});
50+
expect('0x' + digest.toString('hex')).toBe(
51+
'0x84f21d844cc4f15e21f03e16da32687d28e4a1ce8bca3de73a5b8625f02c390b',
52+
);
53+
});
54+
55+
it('uses the canonical Permit2 contract address', () => {
56+
expect(PERMIT2_ADDRESS).toBe('0x000000000022D473030F116dDEE9F6B43aC78BA3');
57+
});
58+
});
59+
60+
describe('createPermit2ExactPayload', () => {
61+
it('builds a permit2Authorization payload with decimal-string fields', () => {
62+
const wallet = generateEvmWallet();
63+
const b64 = createPermit2ExactPayload(
64+
REQUIREMENTS_PERMIT2, wallet.privateKey, wallet.address, 'https://api.example.com/x',
65+
);
66+
const payload = JSON.parse(Buffer.from(b64, 'base64').toString());
67+
68+
expect(payload.x402Version).toBe(2);
69+
expect(payload.accepted).toEqual(REQUIREMENTS_PERMIT2);
70+
expect(payload.resource).toEqual({ url: 'https://api.example.com/x' });
71+
72+
const auth = payload.payload.permit2Authorization;
73+
expect(auth.from).toBe(wallet.address);
74+
expect(auth.spender).toBe('0x2000000000000000000000000000000000000002');
75+
expect(auth.permitted).toEqual({
76+
token: REQUIREMENTS_PERMIT2.asset,
77+
amount: REQUIREMENTS_PERMIT2.amount,
78+
});
79+
expect(auth.witness.to).toBe(REQUIREMENTS_PERMIT2.payTo);
80+
// Wire-format numerics are decimal strings.
81+
expect(auth.nonce).toMatch(/^\d+$/);
82+
expect(auth.deadline).toMatch(/^\d+$/);
83+
expect(auth.witness.validAfter).toMatch(/^\d+$/);
84+
// EIP-3009 fields must be absent.
85+
expect(payload.payload.authorization).toBeUndefined();
86+
expect(payload.payload.signature).toMatch(/^0x[0-9a-f]{130}$/);
87+
});
88+
89+
it('throws when spenderAddress is missing', () => {
90+
const wallet = generateEvmWallet();
91+
const req = {
92+
...REQUIREMENTS_PERMIT2,
93+
extra: { ...REQUIREMENTS_PERMIT2.extra, spenderAddress: undefined },
94+
};
95+
expect(() =>
96+
createPermit2ExactPayload(req, wallet.privateKey, wallet.address, 'u'),
97+
).toThrow(/spenderAddress/);
98+
});
99+
});
100+
101+
describe('createEvmPaymentPayload method routing', () => {
102+
it('routes permit2-exact to the Permit2 payload', () => {
103+
const wallet = generateEvmWallet();
104+
const b64 = createEvmPaymentPayload(
105+
REQUIREMENTS_PERMIT2, wallet.privateKey, wallet.address, 'u',
106+
);
107+
const payload = JSON.parse(Buffer.from(b64, 'base64').toString());
108+
expect(payload.payload.permit2Authorization).toBeDefined();
109+
});
110+
111+
it('keeps the EIP-3009 path when assetTransferMethod is eip3009 or absent', () => {
112+
const wallet = generateEvmWallet();
113+
for (const extra of [
114+
{ name: 'Test Token', version: '1' },
115+
{ name: 'Test Token', version: '1', assetTransferMethod: 'eip3009' },
116+
]) {
117+
const b64 = createEvmPaymentPayload(
118+
{ ...REQUIREMENTS_PERMIT2, extra }, wallet.privateKey, wallet.address, 'u',
119+
);
120+
const payload = JSON.parse(Buffer.from(b64, 'base64').toString());
121+
expect(payload.payload.authorization).toBeDefined();
122+
expect(payload.payload.permit2Authorization).toBeUndefined();
123+
}
124+
});
125+
126+
it('throws on unsupported transfer methods so fallback can continue', () => {
127+
const wallet = generateEvmWallet();
128+
const req = {
129+
...REQUIREMENTS_PERMIT2,
130+
extra: { ...REQUIREMENTS_PERMIT2.extra, assetTransferMethod: 'permit2-upto' },
131+
};
132+
expect(() =>
133+
createEvmPaymentPayload(req, wallet.privateKey, wallet.address, 'u'),
134+
).toThrow(/assetTransferMethod/);
135+
});
136+
});

src/api.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ export class NansenAPI {
493493
* an attemptX402Payment() method so adding a new payment provider only requires
494494
* touching that one method, not hunting inside the retry loop.
495495
*/
496-
async _x402Retry(signature, walletLabel, network, url, body, options = {}) {
496+
async _x402Retry(signature, walletLabel, network, url, body, options = {}, asset = null) {
497497
const paidResponse = await fetch(url, {
498498
method: 'POST',
499499
headers: {
@@ -514,7 +514,7 @@ export class NansenAPI {
514514
if (network) {
515515
try {
516516
const { checkX402Balance } = await import('./x402.js');
517-
const result = await checkX402Balance(network);
517+
const result = await checkX402Balance(network, asset);
518518
if (result !== null && result.balance < 0.25) {
519519
console.error(`[x402] Warning: ${result.symbol} balance low ($${result.balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
520520
}
@@ -651,8 +651,8 @@ export class NansenAPI {
651651
// 1. Try local wallet with fallback across payment networks
652652
try {
653653
const { createPaymentSignatures } = await import('./x402.js');
654-
for await (const { signature, network } of createPaymentSignatures(response, url)) {
655-
const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options);
654+
for await (const { signature, network, asset } of createPaymentSignatures(response, url)) {
655+
const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options, asset);
656656
if (result !== null) return result;
657657
// This payment option was rejected, try next
658658
}

0 commit comments

Comments
 (0)