nansen-cli 1.6.0 → 1.7.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/AGENTS.md +222 -0
- package/README.md +19 -1
- package/SKILL.md +25 -21
- package/TODO.md +17 -0
- package/package.json +1 -1
- package/src/api.js +40 -1
- package/src/cli.js +23 -10
- package/src/crypto.js +215 -0
- package/src/trading.js +1081 -0
- package/src/transfer.js +723 -0
- package/src/wallet.js +764 -0
- package/src/x402-evm.js +207 -0
- package/src/x402-svm.js +474 -0
- package/src/x402.js +205 -0
package/src/x402-evm.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nansen CLI - x402 EVM Auto-Payment
|
|
3
|
+
* Implements EIP-3009 TransferWithAuthorization via EIP-712 typed data signing.
|
|
4
|
+
* Zero external dependencies — uses Node.js built-in crypto + wallet.js keccak256.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { keccak256, signSecp256k1 } from './crypto.js';
|
|
9
|
+
|
|
10
|
+
// ============= EIP-712 Type Hashing =============
|
|
11
|
+
|
|
12
|
+
const DOMAIN_TYPES = [
|
|
13
|
+
{ name: 'name', type: 'string' },
|
|
14
|
+
{ name: 'version', type: 'string' },
|
|
15
|
+
{ name: 'chainId', type: 'uint256' },
|
|
16
|
+
{ name: 'verifyingContract', type: 'address' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const AUTHORIZATION_TYPES = [
|
|
20
|
+
{ name: 'from', type: 'address' },
|
|
21
|
+
{ name: 'to', type: 'address' },
|
|
22
|
+
{ name: 'value', type: 'uint256' },
|
|
23
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
24
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
25
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Encode a type string for EIP-712 typeHash.
|
|
30
|
+
* e.g. "TransferWithAuthorization(address from,address to,uint256 value,...)"
|
|
31
|
+
*/
|
|
32
|
+
function encodeType(typeName, fields) {
|
|
33
|
+
const fieldStrs = fields.map(f => `${f.type} ${f.name}`);
|
|
34
|
+
return `${typeName}(${fieldStrs.join(',')})`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute typeHash = keccak256(encodeType(...))
|
|
39
|
+
*/
|
|
40
|
+
function typeHash(typeName, fields) {
|
|
41
|
+
return keccak256(Buffer.from(encodeType(typeName, fields), 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ABI-encode a single value to 32 bytes based on its EIP-712 type.
|
|
46
|
+
*/
|
|
47
|
+
function encodeValue(fieldType, value) {
|
|
48
|
+
if (fieldType === 'string') {
|
|
49
|
+
// Strings are hashed
|
|
50
|
+
return keccak256(Buffer.from(value, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
if (fieldType === 'bytes') {
|
|
53
|
+
const buf = typeof value === 'string' ? Buffer.from(value.replace(/^0x/, ''), 'hex') : value;
|
|
54
|
+
return keccak256(buf);
|
|
55
|
+
}
|
|
56
|
+
if (fieldType === 'bytes32') {
|
|
57
|
+
if (typeof value === 'string') {
|
|
58
|
+
return Buffer.from(value.replace(/^0x/, ''), 'hex');
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
if (fieldType === 'address') {
|
|
63
|
+
// Left-pad address to 32 bytes
|
|
64
|
+
const addr = value.replace(/^0x/, '').toLowerCase();
|
|
65
|
+
return Buffer.from(addr.padStart(64, '0'), 'hex');
|
|
66
|
+
}
|
|
67
|
+
if (fieldType.startsWith('uint') || fieldType.startsWith('int')) {
|
|
68
|
+
// Encode as 32-byte big-endian
|
|
69
|
+
const hex = BigInt(value).toString(16).padStart(64, '0');
|
|
70
|
+
return Buffer.from(hex, 'hex');
|
|
71
|
+
}
|
|
72
|
+
if (fieldType === 'bool') {
|
|
73
|
+
return Buffer.from((value ? '1' : '0').padStart(64, '0'), 'hex');
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Unsupported EIP-712 field type: ${fieldType}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute struct hash = keccak256(typeHash || encodeValue(field1) || encodeValue(field2) || ...)
|
|
80
|
+
*/
|
|
81
|
+
function hashStruct(typeName, fields, data) {
|
|
82
|
+
const parts = [typeHash(typeName, fields)];
|
|
83
|
+
for (const field of fields) {
|
|
84
|
+
const value = data[field.name];
|
|
85
|
+
if (value === undefined || value === null) {
|
|
86
|
+
throw new Error(`Missing EIP-712 field: ${field.name}`);
|
|
87
|
+
}
|
|
88
|
+
parts.push(encodeValue(field.type, value));
|
|
89
|
+
}
|
|
90
|
+
return keccak256(Buffer.concat(parts));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compute EIP-712 domain separator hash.
|
|
95
|
+
*/
|
|
96
|
+
function hashDomain(domain) {
|
|
97
|
+
return hashStruct('EIP712Domain', DOMAIN_TYPES, domain);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute EIP-712 final hash: keccak256("\x19\x01" || domainSeparator || structHash)
|
|
102
|
+
*/
|
|
103
|
+
export function hashTypedData(domain, primaryType, fields, message) {
|
|
104
|
+
const domainSeparator = hashDomain(domain);
|
|
105
|
+
const structHash = hashStruct(primaryType, fields, message);
|
|
106
|
+
return keccak256(Buffer.concat([
|
|
107
|
+
Buffer.from([0x19, 0x01]),
|
|
108
|
+
domainSeparator,
|
|
109
|
+
structHash,
|
|
110
|
+
]));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============= x402 EVM Payment =============
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract chain ID from CAIP-2 network identifier.
|
|
117
|
+
* e.g. "eip155:8453" → 8453
|
|
118
|
+
*/
|
|
119
|
+
function getChainId(network) {
|
|
120
|
+
const match = network.match(/^eip155:(\d+)$/);
|
|
121
|
+
if (!match) throw new Error(`Invalid EVM network: ${network}`);
|
|
122
|
+
return parseInt(match[1], 10);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create an x402 payment payload for EVM (EIP-3009 TransferWithAuthorization).
|
|
127
|
+
*
|
|
128
|
+
* @param {object} requirements - Parsed PaymentRequirements from 402 response
|
|
129
|
+
* @param {string} privateKeyHex - 32-byte EVM private key as hex
|
|
130
|
+
* @param {string} walletAddress - Signer's EVM address
|
|
131
|
+
* @param {string} resource - Original request URL
|
|
132
|
+
* @returns {string} Base64-encoded PaymentPayload for Payment-Signature header
|
|
133
|
+
*/
|
|
134
|
+
export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddress, resource) {
|
|
135
|
+
const chainId = getChainId(requirements.network);
|
|
136
|
+
const extra = requirements.extra || {};
|
|
137
|
+
|
|
138
|
+
// Token name and version from requirements.extra (set by server/facilitator)
|
|
139
|
+
const tokenName = extra.name;
|
|
140
|
+
const tokenVersion = extra.version || '1';
|
|
141
|
+
|
|
142
|
+
if (!tokenName) {
|
|
143
|
+
throw new Error('EIP-712 domain name missing from requirements.extra');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Generate random nonce (32 bytes)
|
|
147
|
+
const nonce = '0x' + crypto.randomBytes(32).toString('hex');
|
|
148
|
+
|
|
149
|
+
// Validity window: valid now, expires in 1 hour
|
|
150
|
+
const now = Math.floor(Date.now() / 1000);
|
|
151
|
+
const validAfter = '0';
|
|
152
|
+
const validBefore = String(now + 3600);
|
|
153
|
+
|
|
154
|
+
// EIP-712 domain
|
|
155
|
+
const domain = {
|
|
156
|
+
name: tokenName,
|
|
157
|
+
version: tokenVersion,
|
|
158
|
+
chainId,
|
|
159
|
+
verifyingContract: requirements.asset,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// EIP-3009 message
|
|
163
|
+
const message = {
|
|
164
|
+
from: walletAddress,
|
|
165
|
+
to: requirements.pay_to || requirements.payTo,
|
|
166
|
+
value: BigInt(requirements.amount),
|
|
167
|
+
validAfter: BigInt(validAfter),
|
|
168
|
+
validBefore: BigInt(validBefore),
|
|
169
|
+
nonce: nonce,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Hash and sign
|
|
173
|
+
const msgHash = hashTypedData(domain, 'TransferWithAuthorization', AUTHORIZATION_TYPES, message);
|
|
174
|
+
const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
|
|
175
|
+
const signature = '0x' + r.toString('hex') + s.toString('hex') + (27 + v).toString(16);
|
|
176
|
+
|
|
177
|
+
// Build payload (camelCase keys per x402 spec)
|
|
178
|
+
const payload = {
|
|
179
|
+
x402Version: 2,
|
|
180
|
+
payload: {
|
|
181
|
+
authorization: {
|
|
182
|
+
from: walletAddress,
|
|
183
|
+
to: message.to,
|
|
184
|
+
value: String(requirements.amount),
|
|
185
|
+
validAfter: validAfter,
|
|
186
|
+
validBefore: validBefore,
|
|
187
|
+
nonce: nonce,
|
|
188
|
+
},
|
|
189
|
+
signature: signature,
|
|
190
|
+
},
|
|
191
|
+
accepted: requirements,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Add resource as object if provided
|
|
195
|
+
if (resource) {
|
|
196
|
+
payload.resource = { url: resource };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if a network string is an EVM network.
|
|
204
|
+
*/
|
|
205
|
+
export function isEvmNetwork(network) {
|
|
206
|
+
return typeof network === 'string' && network.startsWith('eip155:');
|
|
207
|
+
}
|
package/src/x402-svm.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nansen CLI - x402 Solana Auto-Payment
|
|
3
|
+
* Implements SPL TransferChecked transaction building for x402 payments.
|
|
4
|
+
* Zero external dependencies — uses Node.js built-in crypto + wallet.js base58.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
|
|
9
|
+
// ============= Base58 Encode (inline from wallet.js PR #26) =============
|
|
10
|
+
const BASE58_ALPHABET_STR = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
11
|
+
|
|
12
|
+
export function base58Encode(buf) {
|
|
13
|
+
let num = 0n;
|
|
14
|
+
for (const byte of buf) {
|
|
15
|
+
num = num * 256n + BigInt(byte);
|
|
16
|
+
}
|
|
17
|
+
let str = '';
|
|
18
|
+
while (num > 0n) {
|
|
19
|
+
const rem = Number(num % 58n);
|
|
20
|
+
num = num / 58n;
|
|
21
|
+
str = BASE58_ALPHABET_STR[rem] + str;
|
|
22
|
+
}
|
|
23
|
+
for (const byte of buf) {
|
|
24
|
+
if (byte === 0) str = '1' + str;
|
|
25
|
+
else break;
|
|
26
|
+
}
|
|
27
|
+
return str || '1';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============= Constants =============
|
|
31
|
+
|
|
32
|
+
const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
|
|
33
|
+
const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';
|
|
34
|
+
const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111';
|
|
35
|
+
const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr';
|
|
36
|
+
const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
|
|
37
|
+
const SYSTEM_PROGRAM = '11111111111111111111111111111111';
|
|
38
|
+
|
|
39
|
+
const DEFAULT_COMPUTE_UNIT_LIMIT = 20000;
|
|
40
|
+
const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
|
|
41
|
+
|
|
42
|
+
// ============= Base58 Decode =============
|
|
43
|
+
|
|
44
|
+
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
45
|
+
const BASE58_MAP = new Uint8Array(128);
|
|
46
|
+
for (let i = 0; i < BASE58_ALPHABET.length; i++) {
|
|
47
|
+
BASE58_MAP[BASE58_ALPHABET.charCodeAt(i)] = i;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function base58Decode(str) {
|
|
51
|
+
let num = 0n;
|
|
52
|
+
for (const ch of str) {
|
|
53
|
+
num = num * 58n + BigInt(BASE58_MAP[ch.charCodeAt(0)]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Count leading '1's → leading zero bytes
|
|
57
|
+
let leadingZeros = 0;
|
|
58
|
+
for (const ch of str) {
|
|
59
|
+
if (ch === '1') leadingZeros++;
|
|
60
|
+
else break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (num === 0n) return Buffer.alloc(leadingZeros || 1);
|
|
64
|
+
|
|
65
|
+
// Convert to bytes
|
|
66
|
+
const hex = num.toString(16);
|
|
67
|
+
const paddedHex = hex.length % 2 ? '0' + hex : hex;
|
|
68
|
+
const bytes = Buffer.from(paddedHex, 'hex');
|
|
69
|
+
|
|
70
|
+
return Buffer.concat([Buffer.alloc(leadingZeros), bytes]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decode a base58 string to exactly 32 bytes (left-pad with zeros).
|
|
75
|
+
* Use for Solana public keys and hashes.
|
|
76
|
+
*/
|
|
77
|
+
export function base58DecodePubkey(str) {
|
|
78
|
+
const raw = base58Decode(str);
|
|
79
|
+
if (raw.length === 32) return raw;
|
|
80
|
+
if (raw.length < 32) {
|
|
81
|
+
return Buffer.concat([Buffer.alloc(32 - raw.length), raw]);
|
|
82
|
+
}
|
|
83
|
+
return raw.subarray(raw.length - 32);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============= Compact-u16 Encoding =============
|
|
87
|
+
// (Solana's variable-length integer format, from trading.js pattern)
|
|
88
|
+
|
|
89
|
+
export function encodeCompactU16(value) {
|
|
90
|
+
if (value < 0x80) return Buffer.from([value]);
|
|
91
|
+
if (value < 0x4000) return Buffer.from([
|
|
92
|
+
(value & 0x7f) | 0x80,
|
|
93
|
+
(value >> 7) & 0x7f,
|
|
94
|
+
]);
|
|
95
|
+
return Buffer.from([
|
|
96
|
+
(value & 0x7f) | 0x80,
|
|
97
|
+
((value >> 7) & 0x7f) | 0x80,
|
|
98
|
+
(value >> 14) & 0x03,
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============= PDA Derivation =============
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Derive Associated Token Account (ATA) address.
|
|
106
|
+
* PDA seeds: [owner, tokenProgram, mint] with ATA program.
|
|
107
|
+
*/
|
|
108
|
+
export function deriveATA(ownerBase58, mintBase58, tokenProgramBase58 = TOKEN_PROGRAM) {
|
|
109
|
+
const owner = base58DecodePubkey(ownerBase58);
|
|
110
|
+
const tokenProgram = base58DecodePubkey(tokenProgramBase58);
|
|
111
|
+
const mint = base58DecodePubkey(mintBase58);
|
|
112
|
+
const ataProgramKey = base58DecodePubkey(ATA_PROGRAM);
|
|
113
|
+
|
|
114
|
+
// find_program_address: try nonce 255 down to 0
|
|
115
|
+
// PDA = SHA256(seeds... || programId || "ProgramDerivedAddress")
|
|
116
|
+
// A valid PDA must NOT be on the ed25519 curve.
|
|
117
|
+
// Checking on-curve in pure JS without a full ed25519 implementation is hard.
|
|
118
|
+
// We use the mathematical approach: decode y-coordinate, compute x², check QR.
|
|
119
|
+
for (let nonce = 255; nonce >= 0; nonce--) {
|
|
120
|
+
const hash = crypto.createHash('sha256')
|
|
121
|
+
.update(Buffer.concat([owner, tokenProgram, mint, Buffer.from([nonce]), ataProgramKey, Buffer.from('ProgramDerivedAddress')]))
|
|
122
|
+
.digest();
|
|
123
|
+
|
|
124
|
+
if (!isOnCurve(hash)) {
|
|
125
|
+
return base58Encode(hash);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new Error('Could not derive ATA: no valid PDA found');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if a 32-byte buffer represents a valid ed25519 curve point.
|
|
133
|
+
* Ed25519 curve: -x² + y² = 1 + d*x²*y² over GF(p) where p = 2^255 - 19
|
|
134
|
+
*
|
|
135
|
+
* Decode y from the 32 bytes, compute x² = (y² - 1) / (d*y² + 1),
|
|
136
|
+
* then check if x² is a quadratic residue (QR) mod p.
|
|
137
|
+
*/
|
|
138
|
+
function isOnCurve(bytes) {
|
|
139
|
+
const p = (1n << 255n) - 19n;
|
|
140
|
+
const d = -121665n * modInverse(121666n, p) % p;
|
|
141
|
+
|
|
142
|
+
// Read y-coordinate (little-endian, clear top bit which is sign of x)
|
|
143
|
+
let y = 0n;
|
|
144
|
+
for (let i = 0; i < 32; i++) {
|
|
145
|
+
y |= BigInt(bytes[i]) << (BigInt(i) * 8n);
|
|
146
|
+
}
|
|
147
|
+
y &= (1n << 255n) - 1n; // Clear top bit
|
|
148
|
+
|
|
149
|
+
if (y >= p) return false;
|
|
150
|
+
|
|
151
|
+
// y² mod p
|
|
152
|
+
const y2 = modPow(y, 2n, p);
|
|
153
|
+
|
|
154
|
+
// x² = (y² - 1) * inverse(d*y² + 1) mod p
|
|
155
|
+
const num = ((y2 - 1n) % p + p) % p;
|
|
156
|
+
const den = ((d * y2 + 1n) % p + p) % p;
|
|
157
|
+
const denInv = modInverse(den, p);
|
|
158
|
+
if (denInv === null) return false;
|
|
159
|
+
|
|
160
|
+
const x2 = (num * denInv) % p;
|
|
161
|
+
|
|
162
|
+
// Check if x² is a quadratic residue: x^((p-1)/2) == 1 mod p
|
|
163
|
+
if (x2 === 0n) return true;
|
|
164
|
+
const euler = modPow(x2, (p - 1n) / 2n, p);
|
|
165
|
+
return euler === 1n;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function modPow(base, exp, mod) {
|
|
169
|
+
let result = 1n;
|
|
170
|
+
base = ((base % mod) + mod) % mod;
|
|
171
|
+
while (exp > 0n) {
|
|
172
|
+
if (exp & 1n) result = (result * base) % mod;
|
|
173
|
+
exp >>= 1n;
|
|
174
|
+
base = (base * base) % mod;
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function modInverse(a, mod) {
|
|
180
|
+
return modPow(((a % mod) + mod) % mod, mod - 2n, mod);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============= MessageV0 Builder =============
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build a Solana MessageV0 from accounts and instructions.
|
|
187
|
+
* Simplified builder for x402 payment transactions.
|
|
188
|
+
*/
|
|
189
|
+
function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts }) {
|
|
190
|
+
// All unique accounts in order: feePayer first, then signers, then rest
|
|
191
|
+
const accountMap = new Map();
|
|
192
|
+
const feePayerKey = feePayer;
|
|
193
|
+
|
|
194
|
+
// feePayer is always first, always writable + signer
|
|
195
|
+
accountMap.set(feePayerKey, { isSigner: true, isWritable: true });
|
|
196
|
+
|
|
197
|
+
// Collect all accounts from instructions
|
|
198
|
+
for (const ix of instructions) {
|
|
199
|
+
if (!accountMap.has(ix.programId)) {
|
|
200
|
+
accountMap.set(ix.programId, { isSigner: false, isWritable: false });
|
|
201
|
+
}
|
|
202
|
+
for (const acc of ix.accounts) {
|
|
203
|
+
const existing = accountMap.get(acc.pubkey);
|
|
204
|
+
if (existing) {
|
|
205
|
+
existing.isSigner = existing.isSigner || acc.isSigner;
|
|
206
|
+
existing.isWritable = existing.isWritable || acc.isWritable;
|
|
207
|
+
} else {
|
|
208
|
+
accountMap.set(acc.pubkey, { isSigner: acc.isSigner, isWritable: acc.isWritable });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Sort: signers+writable, signers+readonly, non-signer+writable, non-signer+readonly
|
|
214
|
+
// feePayer always at index 0
|
|
215
|
+
const sortedKeys = [feePayerKey];
|
|
216
|
+
const rest = [...accountMap.entries()].filter(([k]) => k !== feePayerKey);
|
|
217
|
+
|
|
218
|
+
// Signer+writable
|
|
219
|
+
for (const [k, v] of rest) if (v.isSigner && v.isWritable) sortedKeys.push(k);
|
|
220
|
+
// Signer+readonly
|
|
221
|
+
for (const [k, v] of rest) if (v.isSigner && !v.isWritable) sortedKeys.push(k);
|
|
222
|
+
// Non-signer+writable
|
|
223
|
+
for (const [k, v] of rest) if (!v.isSigner && v.isWritable) sortedKeys.push(k);
|
|
224
|
+
// Non-signer+readonly
|
|
225
|
+
for (const [k, v] of rest) if (!v.isSigner && !v.isWritable) sortedKeys.push(k);
|
|
226
|
+
|
|
227
|
+
// Count header values
|
|
228
|
+
let numRequiredSignatures = 0;
|
|
229
|
+
let numReadonlySignedAccounts = 0;
|
|
230
|
+
let numReadonlyUnsignedAccounts = 0;
|
|
231
|
+
|
|
232
|
+
for (const key of sortedKeys) {
|
|
233
|
+
const meta = accountMap.get(key);
|
|
234
|
+
if (meta.isSigner) {
|
|
235
|
+
numRequiredSignatures++;
|
|
236
|
+
if (!meta.isWritable) numReadonlySignedAccounts++;
|
|
237
|
+
} else {
|
|
238
|
+
if (!meta.isWritable) numReadonlyUnsignedAccounts++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Build the account keys index
|
|
243
|
+
const keyIndex = new Map();
|
|
244
|
+
sortedKeys.forEach((k, i) => keyIndex.set(k, i));
|
|
245
|
+
|
|
246
|
+
// Compile instructions
|
|
247
|
+
const compiledInstructions = instructions.map(ix => {
|
|
248
|
+
const programIdIndex = keyIndex.get(ix.programId);
|
|
249
|
+
const accountIndices = ix.accounts.map(a => keyIndex.get(a.pubkey));
|
|
250
|
+
return { programIdIndex, accountIndices, data: ix.data };
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Serialize MessageV0
|
|
254
|
+
// Format: prefix(0x80) | header(3 bytes) | staticAccountKeys | recentBlockhash | instructions | addressTableLookups
|
|
255
|
+
const parts = [];
|
|
256
|
+
|
|
257
|
+
// Version prefix (0x80 = v0)
|
|
258
|
+
parts.push(Buffer.from([0x80]));
|
|
259
|
+
|
|
260
|
+
// Header: numRequiredSignatures, numReadonlySignedAccounts, numReadonlyUnsignedAccounts
|
|
261
|
+
parts.push(Buffer.from([numRequiredSignatures, numReadonlySignedAccounts, numReadonlyUnsignedAccounts]));
|
|
262
|
+
|
|
263
|
+
// Static account keys
|
|
264
|
+
parts.push(encodeCompactU16(sortedKeys.length));
|
|
265
|
+
for (const key of sortedKeys) {
|
|
266
|
+
parts.push(base58DecodePubkey(key));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Recent blockhash (32 bytes)
|
|
270
|
+
parts.push(base58DecodePubkey(recentBlockhash));
|
|
271
|
+
|
|
272
|
+
// Instructions
|
|
273
|
+
parts.push(encodeCompactU16(compiledInstructions.length));
|
|
274
|
+
for (const ix of compiledInstructions) {
|
|
275
|
+
parts.push(Buffer.from([ix.programIdIndex]));
|
|
276
|
+
parts.push(encodeCompactU16(ix.accountIndices.length));
|
|
277
|
+
for (const idx of ix.accountIndices) {
|
|
278
|
+
parts.push(Buffer.from([idx]));
|
|
279
|
+
}
|
|
280
|
+
parts.push(encodeCompactU16(ix.data.length));
|
|
281
|
+
parts.push(ix.data);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Address table lookups (empty for our use case)
|
|
285
|
+
parts.push(encodeCompactU16(0));
|
|
286
|
+
|
|
287
|
+
return Buffer.concat(parts);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ============= Ed25519 Signing =============
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Sign a message with Ed25519 using a Solana keypair (64 bytes: seed + pubkey).
|
|
294
|
+
*/
|
|
295
|
+
function signEd25519(message, keypairHex) {
|
|
296
|
+
const seed = Buffer.from(keypairHex.slice(0, 64), 'hex'); // First 32 bytes
|
|
297
|
+
const keyObj = crypto.createPrivateKey({
|
|
298
|
+
key: Buffer.concat([
|
|
299
|
+
Buffer.from('302e020100300506032b657004220420', 'hex'), // PKCS8 Ed25519 prefix
|
|
300
|
+
seed,
|
|
301
|
+
]),
|
|
302
|
+
format: 'der',
|
|
303
|
+
type: 'pkcs8',
|
|
304
|
+
});
|
|
305
|
+
return crypto.sign(null, message, keyObj);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ============= x402 Solana Payment =============
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Build a Solana x402 payment transaction.
|
|
312
|
+
*
|
|
313
|
+
* This builds an SPL TransferChecked instruction wrapped in a VersionedTransaction.
|
|
314
|
+
* The facilitator is the fee payer (index 0), client signs at index 1.
|
|
315
|
+
*
|
|
316
|
+
* NOTE: This requires a recent blockhash from Solana RPC. For the initial implementation,
|
|
317
|
+
* we fetch it inline. In production, this should be cached.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} requirements - Parsed PaymentRequirements from 402 response
|
|
320
|
+
* @param {string} keypairHex - 128-char hex string (64 bytes: seed + pubkey)
|
|
321
|
+
* @param {string} walletAddress - Signer's Solana address (base58)
|
|
322
|
+
* @param {string} resource - Original request URL
|
|
323
|
+
* @param {string} recentBlockhash - Recent blockhash from Solana RPC (base58)
|
|
324
|
+
* @param {number} decimals - Token decimals (default 6 for USDC)
|
|
325
|
+
* @param {string} tokenProgram - Token program address (auto-detect if not provided)
|
|
326
|
+
* @returns {string} Base64-encoded PaymentPayload for Payment-Signature header
|
|
327
|
+
*/
|
|
328
|
+
export function createSvmPaymentPayload(
|
|
329
|
+
requirements,
|
|
330
|
+
keypairHex,
|
|
331
|
+
walletAddress,
|
|
332
|
+
resource,
|
|
333
|
+
recentBlockhash,
|
|
334
|
+
decimals = 6,
|
|
335
|
+
tokenProgram = TOKEN_PROGRAM,
|
|
336
|
+
) {
|
|
337
|
+
const extra = requirements.extra || {};
|
|
338
|
+
const feePayerStr = extra.feePayer;
|
|
339
|
+
if (!feePayerStr) {
|
|
340
|
+
throw new Error('feePayer is required in requirements.extra for SVM transactions');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const mint = requirements.asset;
|
|
344
|
+
const amount = BigInt(requirements.amount);
|
|
345
|
+
const payTo = requirements.pay_to || requirements.payTo;
|
|
346
|
+
|
|
347
|
+
// Derive ATAs
|
|
348
|
+
const sourceATA = deriveATA(walletAddress, mint, tokenProgram);
|
|
349
|
+
const destATA = deriveATA(payTo, mint, tokenProgram);
|
|
350
|
+
|
|
351
|
+
// Build instructions
|
|
352
|
+
// 1. SetComputeUnitLimit: [2, u32 LE]
|
|
353
|
+
const cuLimitData = Buffer.alloc(5);
|
|
354
|
+
cuLimitData[0] = 2;
|
|
355
|
+
cuLimitData.writeUInt32LE(DEFAULT_COMPUTE_UNIT_LIMIT, 1);
|
|
356
|
+
|
|
357
|
+
// 2. SetComputeUnitPrice: [3, u64 LE]
|
|
358
|
+
const cuPriceData = Buffer.alloc(9);
|
|
359
|
+
cuPriceData[0] = 3;
|
|
360
|
+
cuPriceData.writeBigUInt64LE(BigInt(DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS), 1);
|
|
361
|
+
|
|
362
|
+
// 3. TransferChecked: [12, u64 amount LE, u8 decimals]
|
|
363
|
+
const transferData = Buffer.alloc(10);
|
|
364
|
+
transferData[0] = 12;
|
|
365
|
+
transferData.writeBigUInt64LE(amount, 1);
|
|
366
|
+
transferData[9] = decimals;
|
|
367
|
+
|
|
368
|
+
// 4. Memo: random 16 bytes hex for nonce
|
|
369
|
+
const memoData = Buffer.from(crypto.randomBytes(16).toString('hex'));
|
|
370
|
+
|
|
371
|
+
const instructions = [
|
|
372
|
+
{
|
|
373
|
+
programId: COMPUTE_BUDGET_PROGRAM,
|
|
374
|
+
accounts: [],
|
|
375
|
+
data: cuLimitData,
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
programId: COMPUTE_BUDGET_PROGRAM,
|
|
379
|
+
accounts: [],
|
|
380
|
+
data: cuPriceData,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
programId: tokenProgram,
|
|
384
|
+
accounts: [
|
|
385
|
+
{ pubkey: sourceATA, isSigner: false, isWritable: true },
|
|
386
|
+
{ pubkey: mint, isSigner: false, isWritable: false },
|
|
387
|
+
{ pubkey: destATA, isSigner: false, isWritable: true },
|
|
388
|
+
{ pubkey: walletAddress, isSigner: true, isWritable: false },
|
|
389
|
+
],
|
|
390
|
+
data: transferData,
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
programId: MEMO_PROGRAM,
|
|
394
|
+
accounts: [],
|
|
395
|
+
data: memoData,
|
|
396
|
+
},
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
// Build MessageV0
|
|
400
|
+
const messageBytes = buildMessageV0({
|
|
401
|
+
feePayer: feePayerStr,
|
|
402
|
+
instructions,
|
|
403
|
+
recentBlockhash,
|
|
404
|
+
accounts: null,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Sign: client signs the full message (with 0x80 version prefix already included)
|
|
408
|
+
const clientSignature = signEd25519(messageBytes, keypairHex);
|
|
409
|
+
|
|
410
|
+
// Build transaction: compact-u16(numSignatures) + signatures + message
|
|
411
|
+
// 2 signatures: [facilitator placeholder (64 zero bytes), client signature]
|
|
412
|
+
const numSigs = encodeCompactU16(2);
|
|
413
|
+
const facilitatorPlaceholder = Buffer.alloc(64); // all zeros
|
|
414
|
+
|
|
415
|
+
const txBytes = Buffer.concat([
|
|
416
|
+
numSigs,
|
|
417
|
+
facilitatorPlaceholder,
|
|
418
|
+
clientSignature,
|
|
419
|
+
messageBytes,
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
const txBase64 = txBytes.toString('base64');
|
|
423
|
+
|
|
424
|
+
// Build x402 payload (camelCase per x402 spec)
|
|
425
|
+
const payload = {
|
|
426
|
+
x402Version: 2,
|
|
427
|
+
payload: { transaction: txBase64 },
|
|
428
|
+
accepted: requirements,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
if (resource) {
|
|
432
|
+
payload.resource = { url: resource };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Fetch recent blockhash from Solana RPC.
|
|
440
|
+
*/
|
|
441
|
+
export async function fetchRecentBlockhash(rpcUrl = 'https://api.mainnet-beta.solana.com') {
|
|
442
|
+
const response = await fetch(rpcUrl, {
|
|
443
|
+
method: 'POST',
|
|
444
|
+
headers: { 'Content-Type': 'application/json' },
|
|
445
|
+
body: JSON.stringify({
|
|
446
|
+
jsonrpc: '2.0',
|
|
447
|
+
id: 1,
|
|
448
|
+
method: 'getLatestBlockhash',
|
|
449
|
+
params: [{ commitment: 'finalized' }],
|
|
450
|
+
}),
|
|
451
|
+
});
|
|
452
|
+
const data = await response.json();
|
|
453
|
+
return data.result.value.blockhash;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Get RPC URL for a Solana network identifier.
|
|
458
|
+
*/
|
|
459
|
+
export function getSolanaRpcUrl(network) {
|
|
460
|
+
if (network.includes('devnet') || network === 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1') {
|
|
461
|
+
return 'https://api.devnet.solana.com';
|
|
462
|
+
}
|
|
463
|
+
if (network.includes('testnet') || network === 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z') {
|
|
464
|
+
return 'https://api.testnet.solana.com';
|
|
465
|
+
}
|
|
466
|
+
return 'https://api.mainnet-beta.solana.com';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Check if a network string is a Solana network.
|
|
471
|
+
*/
|
|
472
|
+
export function isSvmNetwork(network) {
|
|
473
|
+
return typeof network === 'string' && network.startsWith('solana:');
|
|
474
|
+
}
|