@m2msentinel/sdk 1.1.1 → 1.2.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/README.md +98 -9
- package/agent_adapter.js +31 -3
- package/eliza_plugin.js +7 -2
- package/index.d.ts +113 -94
- package/index.js +192 -187
- package/m2m_sentinel_sdk.js +1 -1
- package/mcp_server.js +492 -223
- package/package.json +6 -3
- package/typescript/index.ts +218 -204
- package/typescript/package.json +2 -2
- package/typescript/x402.ts +354 -0
- package/x402_signer.js +306 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
export const BASE_USDC_CONTRACT = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
2
|
+
export const BASE_CHAIN_ID = 8453;
|
|
3
|
+
export const EXPECTED_PAYOUT_RECIPIENT = '0x6d6c398390cfb88f1cd42715b84906a0bd6652aa';
|
|
4
|
+
export const DEFAULT_MAX_PRICE_USD = 0.05;
|
|
5
|
+
|
|
6
|
+
export interface X402SignerClientOptions {
|
|
7
|
+
wallet?: any;
|
|
8
|
+
walletSigner?: any;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
expectedRecipient?: string;
|
|
12
|
+
maxPriceUsd?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface X402PaymentChallenge {
|
|
16
|
+
x402Version?: number;
|
|
17
|
+
scheme?: string;
|
|
18
|
+
tokenName?: string;
|
|
19
|
+
tokenVersion?: string;
|
|
20
|
+
assetContract?: string;
|
|
21
|
+
chainId?: number | string;
|
|
22
|
+
payTo?: string;
|
|
23
|
+
recipient?: string;
|
|
24
|
+
amountUnits?: string;
|
|
25
|
+
maxAmountRequired?: string;
|
|
26
|
+
/** x402 v2 nests the offer here. Its presence selects the v2 envelope. */
|
|
27
|
+
accepts?: any[];
|
|
28
|
+
resource?: any;
|
|
29
|
+
asset?: string;
|
|
30
|
+
network?: string;
|
|
31
|
+
extra?: { name?: string; version?: string };
|
|
32
|
+
maxTimeoutSeconds?: number;
|
|
33
|
+
amount?: string;
|
|
34
|
+
price?: string;
|
|
35
|
+
[key: string]: any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The canonical x402 v2 payment payload. */
|
|
39
|
+
export interface X402SignedAuthorizationV2 {
|
|
40
|
+
x402Version: 2;
|
|
41
|
+
resource?: any;
|
|
42
|
+
/** The chosen offer echoed back verbatim. The server deep-equality matches
|
|
43
|
+
* this against its own requirements to learn which offer is being paid. */
|
|
44
|
+
accepted: any;
|
|
45
|
+
payload: {
|
|
46
|
+
authorization: {
|
|
47
|
+
from: string;
|
|
48
|
+
to: string;
|
|
49
|
+
value: string;
|
|
50
|
+
validAfter: string;
|
|
51
|
+
validBefore: string;
|
|
52
|
+
nonce: string;
|
|
53
|
+
};
|
|
54
|
+
signature: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type X402SignedPayload = X402SignedAuthorization | X402SignedAuthorizationV2;
|
|
59
|
+
|
|
60
|
+
export interface X402SignedAuthorization {
|
|
61
|
+
x402Version: number;
|
|
62
|
+
scheme: string;
|
|
63
|
+
network: string;
|
|
64
|
+
token: string;
|
|
65
|
+
assetContract: string;
|
|
66
|
+
authorization: {
|
|
67
|
+
from: string;
|
|
68
|
+
to: string;
|
|
69
|
+
value: string;
|
|
70
|
+
validAfter: number;
|
|
71
|
+
validBefore: number;
|
|
72
|
+
nonce: string;
|
|
73
|
+
v: number;
|
|
74
|
+
r: string;
|
|
75
|
+
s: string;
|
|
76
|
+
signature: string;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parsePriceToUnits(priceStr: string | number, decimals: number = 6): bigint {
|
|
81
|
+
if (typeof priceStr === 'number') return BigInt(Math.round(priceStr * 10 ** decimals));
|
|
82
|
+
const clean = String(priceStr).replace(/[^0-9.]/g, '');
|
|
83
|
+
const [whole, fraction = ''] = clean.split('.');
|
|
84
|
+
const paddedFraction = (fraction + '0'.repeat(decimals)).slice(0, decimals);
|
|
85
|
+
return BigInt(whole || '0') * BigInt(10 ** decimals) + BigInt(paddedFraction);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parsePaymentHeader(value: string | null): any {
|
|
89
|
+
if (!value) return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(value);
|
|
92
|
+
} catch {
|
|
93
|
+
try {
|
|
94
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
95
|
+
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4);
|
|
96
|
+
if (typeof atob === 'function') {
|
|
97
|
+
return JSON.parse(atob(padded));
|
|
98
|
+
}
|
|
99
|
+
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class X402SignerClient {
|
|
107
|
+
public readonly wallet: any;
|
|
108
|
+
public readonly baseUrl: string;
|
|
109
|
+
public readonly timeoutMs: number;
|
|
110
|
+
public readonly expectedRecipient: string;
|
|
111
|
+
public readonly maxPriceUsd: number;
|
|
112
|
+
public readonly maxAmountUnits: bigint;
|
|
113
|
+
|
|
114
|
+
constructor(options: X402SignerClientOptions = {}) {
|
|
115
|
+
this.wallet = options.wallet || options.walletSigner || null;
|
|
116
|
+
this.baseUrl = (options.baseUrl || 'https://api.m2msentinel.com').replace(/\/+$/, '');
|
|
117
|
+
this.timeoutMs = Number(options.timeoutMs || 30000);
|
|
118
|
+
this.expectedRecipient = EXPECTED_PAYOUT_RECIPIENT;
|
|
119
|
+
this.maxPriceUsd = options.maxPriceUsd !== undefined ? Number(options.maxPriceUsd) : DEFAULT_MAX_PRICE_USD;
|
|
120
|
+
this.maxAmountUnits = parsePriceToUnits(this.maxPriceUsd, 6);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async signPaymentAuthorization(challenge: X402PaymentChallenge = {}): Promise<X402SignedPayload> {
|
|
124
|
+
if (!this.wallet) {
|
|
125
|
+
throw new Error('Signer wallet is required to sign x402 payment authorization');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 1. IMMUTABLE LOCAL SECURITY CONSTANTS (Never challenge-controlled)
|
|
129
|
+
const chainId = BASE_CHAIN_ID; // Base Mainnet (8453)
|
|
130
|
+
const tokenContract = BASE_USDC_CONTRACT; // Base USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
|
|
131
|
+
const payTo = EXPECTED_PAYOUT_RECIPIENT; // M2M Sentinel Payout (0x6d6c398390cfb88f1cd42715b84906a0bd6652aa)
|
|
132
|
+
|
|
133
|
+
// x402 v2 nests the offer under `accepts` and is identified by its presence.
|
|
134
|
+
// Reading only the flat shape made every guard below miss silently: they
|
|
135
|
+
// compared undefined and passed, and the amount fell back to a hardcoded
|
|
136
|
+
// default, signing for less than the server demanded.
|
|
137
|
+
const isV2 = Array.isArray(challenge.accepts) && challenge.accepts.length > 0;
|
|
138
|
+
const offer: any = isV2 ? challenge.accepts![0] : challenge;
|
|
139
|
+
|
|
140
|
+
const offeredChainId = typeof offer.network === 'string' && offer.network.startsWith('eip155:')
|
|
141
|
+
? Number(offer.network.slice('eip155:'.length))
|
|
142
|
+
: (offer.chainId || challenge.chainId);
|
|
143
|
+
// v2 carries the address in `asset`; the flat shape carries a symbol there
|
|
144
|
+
// and the address in `assetContract`. Compare addresses to addresses only.
|
|
145
|
+
const offeredAsset = [offer.assetContract, challenge.assetContract, offer.asset, challenge.asset]
|
|
146
|
+
.find((value: any) => typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)) || null;
|
|
147
|
+
const offeredPayTo = offer.payTo || offer.recipient || challenge.payTo;
|
|
148
|
+
const offeredTokenName = (offer.extra && offer.extra.name) || challenge.tokenName;
|
|
149
|
+
const offeredTokenVersion = (offer.extra && offer.extra.version) || challenge.tokenVersion;
|
|
150
|
+
|
|
151
|
+
// 2. STRICT CHALLENGE INTEGRITY CHECKS (Refuse if challenge alters network, asset, or recipient)
|
|
152
|
+
if (offeredChainId && Number(offeredChainId) !== BASE_CHAIN_ID) {
|
|
153
|
+
throw new Error(`[x402 Security Policy] Refusing to sign on unverified network chainId: ${offeredChainId}. Autonomous signer strictly requires Base Mainnet (8453).`);
|
|
154
|
+
}
|
|
155
|
+
if (offeredAsset && offeredAsset.toLowerCase() !== BASE_USDC_CONTRACT.toLowerCase()) {
|
|
156
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unapproved asset: ${offeredAsset}. Autonomous signer strictly requires Base USDC (${BASE_USDC_CONTRACT}).`);
|
|
157
|
+
}
|
|
158
|
+
if (offeredPayTo && offeredPayTo.toLowerCase() !== EXPECTED_PAYOUT_RECIPIENT.toLowerCase()) {
|
|
159
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unexpected recipient: ${offeredPayTo}. Autonomous signer strictly requires ${EXPECTED_PAYOUT_RECIPIENT}.`);
|
|
160
|
+
}
|
|
161
|
+
if (offeredTokenName && offeredTokenName !== 'USD Coin') {
|
|
162
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unexpected tokenName: ${offeredTokenName}. Expected USD Coin.`);
|
|
163
|
+
}
|
|
164
|
+
if (offeredTokenVersion && offeredTokenVersion !== '2') {
|
|
165
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unexpected tokenVersion: ${offeredTokenVersion}. Expected 2.`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 3. STRICT LOCAL PRICE CEILING CHECK
|
|
169
|
+
// Never invent a price. A guessed amount produces an authorization the
|
|
170
|
+
// server refuses, which is indistinguishable from a rejected payment.
|
|
171
|
+
const rawAmount = offer.amount ?? offer.maxAmountRequired ??
|
|
172
|
+
challenge.maxAmountRequired ?? challenge.amountUnits ?? challenge.amount ?? challenge.price;
|
|
173
|
+
let requestedAmountUnits: string | null = null;
|
|
174
|
+
if (typeof rawAmount === 'number' && Number.isInteger(rawAmount) && rawAmount > 0) {
|
|
175
|
+
requestedAmountUnits = String(rawAmount);
|
|
176
|
+
} else if (typeof rawAmount === 'string' && /^[0-9]+$/.test(rawAmount.trim())) {
|
|
177
|
+
requestedAmountUnits = rawAmount.trim();
|
|
178
|
+
} else if (typeof rawAmount === 'string' && /[$.]/.test(rawAmount)) {
|
|
179
|
+
requestedAmountUnits = parsePriceToUnits(rawAmount, 6).toString();
|
|
180
|
+
}
|
|
181
|
+
if (!requestedAmountUnits || !/^[0-9]+$/.test(requestedAmountUnits) || BigInt(requestedAmountUnits) <= BigInt(0)) {
|
|
182
|
+
throw new Error('[x402] The challenge carried no readable amount. Refusing to guess a price.');
|
|
183
|
+
}
|
|
184
|
+
if (BigInt(requestedAmountUnits) > this.maxAmountUnits) {
|
|
185
|
+
throw new Error(`[x402 Security Policy] Requested amount (${requestedAmountUnits} units) exceeds local client authorized price ceiling (${this.maxAmountUnits.toString()} units / $${this.maxPriceUsd}).`);
|
|
186
|
+
}
|
|
187
|
+
const amountUnits = requestedAmountUnits;
|
|
188
|
+
|
|
189
|
+
const now = Math.floor(Date.now() / 1000);
|
|
190
|
+
// v2 servers bound the window with `maxTimeoutSeconds`; a longer window
|
|
191
|
+
// than advertised is not more permissive, just outside what settles.
|
|
192
|
+
const validAfter: any = isV2 ? '0' : now - 60;
|
|
193
|
+
const validBefore: any = isV2 ? String(now + (Number(offer.maxTimeoutSeconds) || 120)) : now + 3600;
|
|
194
|
+
|
|
195
|
+
// Generate 32-byte hex nonce
|
|
196
|
+
let nonce = '0x';
|
|
197
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
198
|
+
const bytes = new Uint8Array(32);
|
|
199
|
+
crypto.getRandomValues(bytes);
|
|
200
|
+
nonce += Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
201
|
+
} else {
|
|
202
|
+
const cryptoNode = require('crypto');
|
|
203
|
+
nonce += cryptoNode.randomBytes(32).toString('hex');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const domain = {
|
|
207
|
+
name: 'USD Coin',
|
|
208
|
+
version: '2',
|
|
209
|
+
chainId,
|
|
210
|
+
verifyingContract: tokenContract
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const types = {
|
|
214
|
+
TransferWithAuthorization: [
|
|
215
|
+
{ name: 'from', type: 'address' },
|
|
216
|
+
{ name: 'to', type: 'address' },
|
|
217
|
+
{ name: 'value', type: 'uint256' },
|
|
218
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
219
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
220
|
+
{ name: 'nonce', type: 'bytes32' }
|
|
221
|
+
]
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const fromAddress = typeof this.wallet.getAddress === 'function'
|
|
225
|
+
? await this.wallet.getAddress()
|
|
226
|
+
: (this.wallet.address || this.wallet.account?.address);
|
|
227
|
+
|
|
228
|
+
const message = {
|
|
229
|
+
from: fromAddress,
|
|
230
|
+
to: payTo,
|
|
231
|
+
value: amountUnits.toString(),
|
|
232
|
+
validAfter,
|
|
233
|
+
validBefore,
|
|
234
|
+
nonce
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let signature: string | { r: string; s: string; v: number };
|
|
238
|
+
if (typeof this.wallet.signTypedData === 'function') {
|
|
239
|
+
signature = await this.wallet.signTypedData(domain, types, message);
|
|
240
|
+
} else if (typeof this.wallet._signTypedData === 'function') {
|
|
241
|
+
signature = await this.wallet._signTypedData(domain, types, message);
|
|
242
|
+
} else if (typeof this.wallet.signTypedDataV4 === 'function') {
|
|
243
|
+
signature = await this.wallet.signTypedDataV4({ domain, types, message, primaryType: 'TransferWithAuthorization' });
|
|
244
|
+
} else {
|
|
245
|
+
throw new Error('Wallet does not implement EIP-712 signTypedData');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let v: number, r: string, s: string;
|
|
249
|
+
if (typeof signature === 'string') {
|
|
250
|
+
const clean = signature.startsWith('0x') ? signature.slice(2) : signature;
|
|
251
|
+
r = '0x' + clean.slice(0, 64);
|
|
252
|
+
s = '0x' + clean.slice(64, 128);
|
|
253
|
+
v = parseInt(clean.slice(128, 130), 16);
|
|
254
|
+
if (v < 27) v += 27;
|
|
255
|
+
} else {
|
|
256
|
+
r = signature.r;
|
|
257
|
+
s = signature.s;
|
|
258
|
+
v = signature.v;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const flatSignature = typeof signature === 'string'
|
|
262
|
+
? signature
|
|
263
|
+
: '0x' + r.slice(2) + s.slice(2) + v.toString(16).padStart(2, '0');
|
|
264
|
+
|
|
265
|
+
if (isV2) {
|
|
266
|
+
// The canonical v2 envelope. Omitting `accepted` does not read as "no
|
|
267
|
+
// payment": it crashes the server's offer matcher, which then answers 402
|
|
268
|
+
// with an opaque body. There is deliberately no top-level scheme/network
|
|
269
|
+
// in v2 -- both live inside `accepted`.
|
|
270
|
+
return {
|
|
271
|
+
x402Version: 2,
|
|
272
|
+
resource: challenge.resource,
|
|
273
|
+
accepted: offer,
|
|
274
|
+
payload: {
|
|
275
|
+
authorization: {
|
|
276
|
+
from: fromAddress,
|
|
277
|
+
to: payTo,
|
|
278
|
+
value: amountUnits.toString(),
|
|
279
|
+
validAfter: String(validAfter),
|
|
280
|
+
validBefore: String(validBefore),
|
|
281
|
+
nonce
|
|
282
|
+
},
|
|
283
|
+
signature: flatSignature
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Legacy flat envelope, for servers that do not advertise `accepts`.
|
|
289
|
+
return {
|
|
290
|
+
x402Version: 2,
|
|
291
|
+
scheme: 'eip3009',
|
|
292
|
+
network: `eip155:${chainId}`,
|
|
293
|
+
token: tokenContract,
|
|
294
|
+
assetContract: tokenContract,
|
|
295
|
+
authorization: {
|
|
296
|
+
from: fromAddress,
|
|
297
|
+
to: payTo,
|
|
298
|
+
value: amountUnits.toString(),
|
|
299
|
+
validAfter,
|
|
300
|
+
validBefore,
|
|
301
|
+
nonce,
|
|
302
|
+
v,
|
|
303
|
+
r,
|
|
304
|
+
s,
|
|
305
|
+
signature: flatSignature
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async fetchWithAutoPayment(path: string, options: any = {}): Promise<any> {
|
|
311
|
+
const url = path.startsWith('http') ? path : `${this.baseUrl}/${path.replace(/^\/+/, '')}`;
|
|
312
|
+
const headers = { 'Accept': 'application/json', 'User-Agent': '@m2msentinel/sdk-ts/1.2.0', ...(options.headers || {}) };
|
|
313
|
+
|
|
314
|
+
let res = await fetch(url, { method: options.method || 'GET', headers, body: options.body });
|
|
315
|
+
if (res.status !== 402) {
|
|
316
|
+
return res;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const challengeHeader = res.headers.get('PAYMENT-REQUIRED') || res.headers.get('x402-payment-required');
|
|
320
|
+
let challenge: any = parsePaymentHeader(challengeHeader);
|
|
321
|
+
|
|
322
|
+
if (!challenge) {
|
|
323
|
+
try {
|
|
324
|
+
const bodyJson = await res.clone().json();
|
|
325
|
+
// Pass the whole PaymentRequired, not just accepts[0]: the signer
|
|
326
|
+
// needs `resource` and must echo the offer back as `accepted`.
|
|
327
|
+
challenge = bodyJson.accepts ? bodyJson : bodyJson.paymentRequired;
|
|
328
|
+
} catch {
|
|
329
|
+
// Body was not JSON
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!challenge) {
|
|
334
|
+
throw new Error('HTTP 402 received but no valid x402 payment challenge was found in headers or response body.');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const paymentPayload = await this.signPaymentAuthorization(challenge);
|
|
338
|
+
const paymentB64 = typeof btoa === 'function'
|
|
339
|
+
? btoa(JSON.stringify(paymentPayload))
|
|
340
|
+
: Buffer.from(JSON.stringify(paymentPayload)).toString('base64');
|
|
341
|
+
|
|
342
|
+
const retryHeaders = {
|
|
343
|
+
...headers,
|
|
344
|
+
'PAYMENT-SIGNATURE': paymentB64,
|
|
345
|
+
'x402-payment-authorization': paymentB64
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
return fetch(url, { method: options.method || 'GET', headers: retryHeaders, body: options.body });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function x402SignerClient(options: X402SignerClientOptions = {}): X402SignerClient {
|
|
353
|
+
return new X402SignerClient(options);
|
|
354
|
+
}
|
package/x402_signer.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Facilitator-Independent Headless x402 Signer Client.
|
|
5
|
+
*
|
|
6
|
+
* Implements automated HTTP 402 challenge negotiation and EIP-712 / EIP-3009
|
|
7
|
+
* transfer authorization signing directly on Base mainnet (chainId: 8453).
|
|
8
|
+
* Zero browser or UI wallet dependencies — pure headless operation for autonomous agents.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
const BASE_USDC_CONTRACT = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
16
|
+
const BASE_CHAIN_ID = 8453;
|
|
17
|
+
|
|
18
|
+
function parsePaymentHeader(value) {
|
|
19
|
+
if (!value) return null;
|
|
20
|
+
if (typeof value === 'object') return value;
|
|
21
|
+
try { return JSON.parse(value); } catch (_) {}
|
|
22
|
+
try {
|
|
23
|
+
const normalized = String(value).replace(/-/g, '+').replace(/_/g, '/');
|
|
24
|
+
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4);
|
|
25
|
+
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
26
|
+
} catch (_) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parsePriceToUnits(priceStr, decimals = 6) {
|
|
32
|
+
if (typeof priceStr === 'number') return BigInt(Math.round(priceStr * 10 ** decimals));
|
|
33
|
+
const clean = String(priceStr).replace(/[^0-9.]/g, '');
|
|
34
|
+
const [whole, fraction = ''] = clean.split('.');
|
|
35
|
+
const paddedFraction = (fraction + '0'.repeat(decimals)).slice(0, decimals);
|
|
36
|
+
return BigInt(whole || '0') * BigInt(10 ** decimals) + BigInt(paddedFraction);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const EXPECTED_PAYOUT_RECIPIENT = '0x6d6c398390cfb88f1cd42715b84906a0bd6652aa';
|
|
40
|
+
const DEFAULT_MAX_PRICE_USD = 0.05; // 5 cents maximum per autonomous request
|
|
41
|
+
const EIP712_TOKEN_NAME = 'USD Coin';
|
|
42
|
+
const EIP712_TOKEN_VERSION = '2';
|
|
43
|
+
|
|
44
|
+
class X402SignerClient {
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.wallet = options.wallet || options.walletSigner || null;
|
|
47
|
+
this.baseUrl = (options.baseUrl || 'https://api.m2msentinel.com').replace(/\/+$/, '');
|
|
48
|
+
this.timeoutMs = Number(options.timeoutMs || 30000);
|
|
49
|
+
this.expectedRecipient = EXPECTED_PAYOUT_RECIPIENT;
|
|
50
|
+
this.maxPriceUsd = options.maxPriceUsd !== undefined ? Number(options.maxPriceUsd) : DEFAULT_MAX_PRICE_USD;
|
|
51
|
+
this.maxAmountUnits = parsePriceToUnits(this.maxPriceUsd, 6);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async signPaymentAuthorization(challenge = {}) {
|
|
55
|
+
if (!this.wallet) {
|
|
56
|
+
throw new Error('Signer wallet is required to sign x402 payment authorization');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 1. IMMUTABLE LOCAL SECURITY CONSTANTS (Never challenge-controlled)
|
|
60
|
+
const chainId = BASE_CHAIN_ID; // Base Mainnet (8453)
|
|
61
|
+
const tokenContract = BASE_USDC_CONTRACT; // Base USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
|
|
62
|
+
const payTo = EXPECTED_PAYOUT_RECIPIENT; // M2M Sentinel Payout (0x6d6c398390cfb88f1cd42715b84906a0bd6652aa)
|
|
63
|
+
|
|
64
|
+
// x402 v2 nests the offer under `accepts` and is identified by its presence.
|
|
65
|
+
// Reading only the flat shape made every check below miss silently: the
|
|
66
|
+
// guards passed vacuously because the fields they read were undefined, and
|
|
67
|
+
// the amount fell back to a hardcoded default, signing for less than the
|
|
68
|
+
// server demanded. Read the offer first, keep the flat shape as fallback.
|
|
69
|
+
const isV2 = Array.isArray(challenge.accepts) && challenge.accepts.length > 0;
|
|
70
|
+
const offer = isV2 ? challenge.accepts[0] : challenge;
|
|
71
|
+
|
|
72
|
+
const offeredChainId = typeof offer.network === 'string' && offer.network.startsWith('eip155:')
|
|
73
|
+
? Number(offer.network.slice('eip155:'.length))
|
|
74
|
+
: (offer.chainId || challenge.chainId);
|
|
75
|
+
// v2 carries the address in `asset`; the flat shape carries a symbol there
|
|
76
|
+
// and the address in `assetContract`. Compare addresses to addresses only.
|
|
77
|
+
const offeredAsset = [offer.assetContract, challenge.assetContract, offer.asset, challenge.asset]
|
|
78
|
+
.find((value) => typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)) || null;
|
|
79
|
+
const offeredPayTo = offer.payTo || offer.recipient || challenge.payTo;
|
|
80
|
+
const offeredTokenName = (offer.extra && offer.extra.name) || challenge.tokenName;
|
|
81
|
+
const offeredTokenVersion = (offer.extra && offer.extra.version) || challenge.tokenVersion;
|
|
82
|
+
|
|
83
|
+
// 2. STRICT CHALLENGE INTEGRITY CHECKS (Refuse if challenge alters network, asset, or recipient)
|
|
84
|
+
if (offeredChainId && Number(offeredChainId) !== BASE_CHAIN_ID) {
|
|
85
|
+
throw new Error(`[x402 Security Policy] Refusing to sign on unverified network chainId: ${offeredChainId}. Autonomous signer strictly requires Base Mainnet (8453).`);
|
|
86
|
+
}
|
|
87
|
+
if (offeredAsset && offeredAsset.toLowerCase() !== BASE_USDC_CONTRACT.toLowerCase()) {
|
|
88
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unapproved asset: ${offeredAsset}. Autonomous signer strictly requires Base USDC (${BASE_USDC_CONTRACT}).`);
|
|
89
|
+
}
|
|
90
|
+
if (offeredPayTo && offeredPayTo.toLowerCase() !== EXPECTED_PAYOUT_RECIPIENT.toLowerCase()) {
|
|
91
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unexpected recipient: ${offeredPayTo}. Autonomous signer strictly requires ${EXPECTED_PAYOUT_RECIPIENT}.`);
|
|
92
|
+
}
|
|
93
|
+
if (offeredTokenName && offeredTokenName !== EIP712_TOKEN_NAME) {
|
|
94
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unexpected tokenName: ${offeredTokenName}. Expected ${EIP712_TOKEN_NAME}.`);
|
|
95
|
+
}
|
|
96
|
+
if (offeredTokenVersion && offeredTokenVersion !== EIP712_TOKEN_VERSION) {
|
|
97
|
+
throw new Error(`[x402 Security Policy] Refusing to sign for unexpected tokenVersion: ${offeredTokenVersion}. Expected ${EIP712_TOKEN_VERSION}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. STRICT LOCAL PRICE CEILING CHECK
|
|
101
|
+
// Never invent a price. A guessed amount produces an authorization the
|
|
102
|
+
// server refuses, which is indistinguishable from a rejected payment.
|
|
103
|
+
// v2 states base units ("20000"); the flat shape states a price ("$0.005").
|
|
104
|
+
const rawAmount = offer.amount ?? offer.maxAmountRequired ??
|
|
105
|
+
challenge.maxAmountRequired ?? challenge.amountUnits ?? challenge.amount ?? challenge.price;
|
|
106
|
+
let requestedAmountUnits = null;
|
|
107
|
+
if (typeof rawAmount === 'number' && Number.isInteger(rawAmount) && rawAmount > 0) {
|
|
108
|
+
requestedAmountUnits = String(rawAmount);
|
|
109
|
+
} else if (typeof rawAmount === 'string' && /^\d+$/.test(rawAmount.trim())) {
|
|
110
|
+
requestedAmountUnits = rawAmount.trim();
|
|
111
|
+
} else if (typeof rawAmount === 'string' && /[$.]/.test(rawAmount)) {
|
|
112
|
+
requestedAmountUnits = parsePriceToUnits(rawAmount, 6).toString();
|
|
113
|
+
}
|
|
114
|
+
if (!requestedAmountUnits || !/^\d+$/.test(requestedAmountUnits) || BigInt(requestedAmountUnits) <= 0n) {
|
|
115
|
+
throw new Error('[x402] The challenge carried no readable amount. Refusing to guess a price.');
|
|
116
|
+
}
|
|
117
|
+
if (BigInt(requestedAmountUnits) > this.maxAmountUnits) {
|
|
118
|
+
throw new Error(`[x402 Security Policy] Requested amount (${requestedAmountUnits} units) exceeds local client authorized price ceiling (${this.maxAmountUnits.toString()} units / $${this.maxPriceUsd}).`);
|
|
119
|
+
}
|
|
120
|
+
const amountUnits = requestedAmountUnits;
|
|
121
|
+
|
|
122
|
+
const now = Math.floor(Date.now() / 1000);
|
|
123
|
+
// v2 servers bound the window with `maxTimeoutSeconds`; a longer window
|
|
124
|
+
// than advertised is not more permissive, just outside what settles.
|
|
125
|
+
const validAfter = isV2 ? '0' : now - 60;
|
|
126
|
+
const validBefore = isV2 ? String(now + (Number(offer.maxTimeoutSeconds) || 120)) : now + 3600;
|
|
127
|
+
const nonce = '0x' + crypto.randomBytes(32).toString('hex');
|
|
128
|
+
|
|
129
|
+
const domain = {
|
|
130
|
+
name: EIP712_TOKEN_NAME,
|
|
131
|
+
version: EIP712_TOKEN_VERSION,
|
|
132
|
+
chainId,
|
|
133
|
+
verifyingContract: tokenContract
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const types = {
|
|
137
|
+
TransferWithAuthorization: [
|
|
138
|
+
{ name: 'from', type: 'address' },
|
|
139
|
+
{ name: 'to', type: 'address' },
|
|
140
|
+
{ name: 'value', type: 'uint256' },
|
|
141
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
142
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
143
|
+
{ name: 'nonce', type: 'bytes32' }
|
|
144
|
+
]
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const message = {
|
|
148
|
+
from: typeof this.wallet.getAddress === 'function' ? await this.wallet.getAddress() : (this.wallet.address || this.wallet.account?.address),
|
|
149
|
+
to: payTo,
|
|
150
|
+
value: amountUnits.toString(),
|
|
151
|
+
validAfter,
|
|
152
|
+
validBefore,
|
|
153
|
+
nonce
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let signature;
|
|
157
|
+
if (typeof this.wallet.signTypedData === 'function') {
|
|
158
|
+
signature = await this.wallet.signTypedData(domain, types, message);
|
|
159
|
+
} else if (typeof this.wallet._signTypedData === 'function') {
|
|
160
|
+
signature = await this.wallet._signTypedData(domain, types, message);
|
|
161
|
+
} else if (typeof this.wallet.signTypedDataV4 === 'function') {
|
|
162
|
+
signature = await this.wallet.signTypedDataV4({ domain, types, message, primaryType: 'TransferWithAuthorization' });
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error('Wallet does not implement EIP-712 signTypedData');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let v, r, s;
|
|
168
|
+
if (typeof signature === 'string') {
|
|
169
|
+
const clean = signature.startsWith('0x') ? signature.slice(2) : signature;
|
|
170
|
+
r = '0x' + clean.slice(0, 64);
|
|
171
|
+
s = '0x' + clean.slice(64, 128);
|
|
172
|
+
v = parseInt(clean.slice(128, 130), 16);
|
|
173
|
+
if (v < 27) v += 27;
|
|
174
|
+
} else {
|
|
175
|
+
r = signature.r;
|
|
176
|
+
s = signature.s;
|
|
177
|
+
v = signature.v;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isV2) {
|
|
181
|
+
// The canonical v2 envelope. `accepted` is the offer echoed back verbatim:
|
|
182
|
+
// the server deep-equality matches it against its own requirements to learn
|
|
183
|
+
// which offer is being paid. Omitting it does not read as "no payment" --
|
|
184
|
+
// it crashes the server's matcher, which then answers 402 with an opaque
|
|
185
|
+
// body. Reconstructing the offer fails the same way, so it is passed
|
|
186
|
+
// through untouched. Note there is no top-level scheme/network in v2:
|
|
187
|
+
// both live inside `accepted`.
|
|
188
|
+
return {
|
|
189
|
+
x402Version: 2,
|
|
190
|
+
resource: challenge.resource,
|
|
191
|
+
accepted: offer,
|
|
192
|
+
payload: {
|
|
193
|
+
authorization: {
|
|
194
|
+
from: message.from,
|
|
195
|
+
to: message.to,
|
|
196
|
+
value: message.value,
|
|
197
|
+
validAfter: message.validAfter,
|
|
198
|
+
validBefore: message.validBefore,
|
|
199
|
+
nonce: message.nonce
|
|
200
|
+
},
|
|
201
|
+
signature: typeof signature === 'string' ? signature : `0x${r.slice(2)}${s.slice(2)}${v.toString(16)}`
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Legacy flat envelope, for servers that do not advertise `accepts`.
|
|
207
|
+
return {
|
|
208
|
+
x402Version: 2,
|
|
209
|
+
scheme: 'eip3009',
|
|
210
|
+
network: 'eip155:8453',
|
|
211
|
+
token: tokenContract,
|
|
212
|
+
authorization: {
|
|
213
|
+
from: message.from,
|
|
214
|
+
to: message.to,
|
|
215
|
+
value: message.value,
|
|
216
|
+
validAfter: message.validAfter,
|
|
217
|
+
validBefore: message.validBefore,
|
|
218
|
+
nonce: message.nonce,
|
|
219
|
+
v,
|
|
220
|
+
r,
|
|
221
|
+
s
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async fetchWithAutoPayment(path, options = {}) {
|
|
227
|
+
const url = path.startsWith('http') ? path : `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
|
228
|
+
const initialRes = await this._makeHttpRequest(url, options);
|
|
229
|
+
|
|
230
|
+
if (initialRes.status !== 402) {
|
|
231
|
+
return initialRes;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const challengeHeader = initialRes.headers['payment-required'] || initialRes.headers['x402-payment-required'] || initialRes.headers['www-authenticate'];
|
|
235
|
+
const challenge = parsePaymentHeader(challengeHeader) || (initialRes.json ? initialRes.json : null);
|
|
236
|
+
|
|
237
|
+
if (!challenge) {
|
|
238
|
+
throw new Error('Received HTTP 402 Payment Required but could not parse payment challenge header');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const paymentPayload = await this.signPaymentAuthorization(challenge);
|
|
242
|
+
const paymentHeaderValue = Buffer.from(JSON.stringify(paymentPayload)).toString('base64');
|
|
243
|
+
|
|
244
|
+
const paymentOptions = {
|
|
245
|
+
...options,
|
|
246
|
+
headers: {
|
|
247
|
+
...(options.headers || {}),
|
|
248
|
+
'PAYMENT-SIGNATURE': paymentHeaderValue,
|
|
249
|
+
'Accept': 'application/json'
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return this._makeHttpRequest(url, paymentOptions);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_makeHttpRequest(urlStr, options = {}) {
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
const url = new URL(urlStr);
|
|
259
|
+
const isHttps = url.protocol === 'https:';
|
|
260
|
+
const client = isHttps ? https : http;
|
|
261
|
+
|
|
262
|
+
const reqOptions = {
|
|
263
|
+
hostname: url.hostname,
|
|
264
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
265
|
+
path: url.pathname + url.search,
|
|
266
|
+
method: options.method || 'GET',
|
|
267
|
+
headers: {
|
|
268
|
+
'User-Agent': 'M2M-Sentinel-X402Signer/1.2.0',
|
|
269
|
+
'Accept': 'application/json',
|
|
270
|
+
...(options.headers || {})
|
|
271
|
+
},
|
|
272
|
+
timeout: options.timeoutMs || this.timeoutMs
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const req = client.request(reqOptions, (res) => {
|
|
276
|
+
let rawData = '';
|
|
277
|
+
res.on('data', (chunk) => { rawData += chunk; });
|
|
278
|
+
res.on('end', () => {
|
|
279
|
+
let json = null;
|
|
280
|
+
try { json = JSON.parse(rawData); } catch (_) {}
|
|
281
|
+
resolve({
|
|
282
|
+
status: res.statusCode,
|
|
283
|
+
headers: res.headers,
|
|
284
|
+
text: rawData,
|
|
285
|
+
json
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
req.on('error', (err) => reject(err));
|
|
291
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
|
|
292
|
+
|
|
293
|
+
if (options.body) {
|
|
294
|
+
req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
|
|
295
|
+
}
|
|
296
|
+
req.end();
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
X402SignerClient,
|
|
303
|
+
x402SignerClient: (opts) => new X402SignerClient(opts),
|
|
304
|
+
parsePaymentHeader,
|
|
305
|
+
parsePriceToUnits
|
|
306
|
+
};
|