402-trinity-gaming 0.1.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/INTEGRATION.md +188 -0
- package/LICENSE +110 -0
- package/README.md +105 -0
- package/dist/batch-manager.d.ts +201 -0
- package/dist/batch-manager.js +291 -0
- package/dist/batch-manager.min.js +1 -0
- package/dist/budget-file.d.ts +106 -0
- package/dist/budget-file.js +270 -0
- package/dist/budget-file.min.js +1 -0
- package/dist/evm-tx.d.ts +55 -0
- package/dist/evm-tx.js +195 -0
- package/dist/evm-tx.min.js +1 -0
- package/dist/proceeds-fee.d.ts +87 -0
- package/dist/proceeds-fee.js +158 -0
- package/dist/proceeds-fee.min.js +1 -0
- package/dist/seller.d.ts +121 -0
- package/dist/seller.js +136 -0
- package/dist/seller.min.js +1 -0
- package/dist/signer.d.ts +64 -0
- package/dist/signer.js +61 -0
- package/dist/signer.min.js +1 -0
- package/dist/storefront.d.ts +143 -0
- package/dist/storefront.js +173 -0
- package/dist/storefront.min.js +1 -0
- package/dist/x402.d.ts +391 -0
- package/dist/x402.js +930 -0
- package/dist/x402.min.js +1 -0
- package/package.json +120 -0
- package/src/batch-manager.ts +351 -0
- package/src/budget-file.ts +314 -0
- package/src/evm-tx.ts +244 -0
- package/src/proceeds-fee.ts +202 -0
- package/src/seller.ts +252 -0
- package/src/signer.ts +129 -0
- package/src/storefront.ts +286 -0
- package/src/x402.ts +1255 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed durable budget store. OPTIONAL - not part of the core, so it does not
|
|
3
|
+
* count against the wrapper's footprint. Import it only if you want one.
|
|
4
|
+
*
|
|
5
|
+
* Needs no Cloudflare, no database, no network. Works anywhere Node/Bun/Deno runs:
|
|
6
|
+
* your laptop, a VPS, a Raspberry Pi, a robotics controller.
|
|
7
|
+
*
|
|
8
|
+
* import { createX402Fetch } from './x402.ts';
|
|
9
|
+
* import { createFileBudgetStore } from './budget-file.ts';
|
|
10
|
+
*
|
|
11
|
+
* const x402Fetch = createX402Fetch({
|
|
12
|
+
* privateKey: process.env.X402_PRIVATE_KEY,
|
|
13
|
+
* policy: { maxAmountPerRequest: '5000', totalBudget: '1000000', allowNetworks: ['base'] },
|
|
14
|
+
* budgetStore: createFileBudgetStore('./.x402-budget.json'),
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* The spend total survives process restarts, which is the entire point: an in-memory
|
|
18
|
+
* budget resets every time the process starts, so on mainnet it caps nothing.
|
|
19
|
+
*
|
|
20
|
+
* Concurrency: uses an exclusive lock file (O_EXCL), so multiple processes on the same
|
|
21
|
+
* machine cannot both pass the same check. It does NOT coordinate across machines - for
|
|
22
|
+
* that, back the same interface with Redis/Postgres/Workers KV instead.
|
|
23
|
+
*/
|
|
24
|
+
import { openSync, closeSync, unlinkSync, readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
export interface BudgetStore {
|
|
27
|
+
reserve: (amount: bigint, totalBudget: bigint) => Promise<boolean>;
|
|
28
|
+
release?: (amount: bigint) => Promise<void>;
|
|
29
|
+
/** Current spend, for reporting. */
|
|
30
|
+
spent: () => bigint;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
34
|
+
|
|
35
|
+
export function createFileBudgetStore(path: string, opts: { lockTimeoutMs?: number } = {}): BudgetStore {
|
|
36
|
+
const lockPath = path + '.lock';
|
|
37
|
+
const timeout = opts.lockTimeoutMs ?? 5000;
|
|
38
|
+
|
|
39
|
+
const read = (): bigint => {
|
|
40
|
+
if (!existsSync(path)) return 0n;
|
|
41
|
+
try {
|
|
42
|
+
const j = JSON.parse(readFileSync(path, 'utf8'));
|
|
43
|
+
const v = BigInt(j.spent ?? '0');
|
|
44
|
+
return v < 0n ? 0n : v;
|
|
45
|
+
} catch {
|
|
46
|
+
// A corrupt ledger must NOT read as zero - that would silently reset the budget
|
|
47
|
+
// and re-open unlimited spending. Fail closed instead.
|
|
48
|
+
throw new Error(`x402 budget file is unreadable: ${path}. Refusing to treat it as zero spend.`);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const write = (v: bigint): void => {
|
|
53
|
+
const tmp = path + '.tmp';
|
|
54
|
+
writeFileSync(tmp, JSON.stringify({ spent: v.toString(), updated: new Date().toISOString() }));
|
|
55
|
+
renameSync(tmp, path); // atomic replace on the same filesystem
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function withLock<T>(fn: () => T): Promise<T> {
|
|
59
|
+
const deadline = Date.now() + timeout;
|
|
60
|
+
let fd: number | undefined;
|
|
61
|
+
for (;;) {
|
|
62
|
+
try { fd = openSync(lockPath, 'wx'); break; } // O_CREAT|O_EXCL - one winner
|
|
63
|
+
catch {
|
|
64
|
+
if (Date.now() > deadline) throw new Error(`x402 budget lock timed out: ${lockPath}`);
|
|
65
|
+
await sleep(15);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try { return fn(); }
|
|
69
|
+
finally {
|
|
70
|
+
try { closeSync(fd!); } catch { /* already closed */ }
|
|
71
|
+
try { unlinkSync(lockPath); } catch { /* already gone */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
/** Atomic check-and-increment. Returns false if the payment would exceed the budget. */
|
|
77
|
+
reserve: (amount, totalBudget) => withLock(() => {
|
|
78
|
+
const now = read();
|
|
79
|
+
if (now + amount > totalBudget) return false;
|
|
80
|
+
write(now + amount);
|
|
81
|
+
return true;
|
|
82
|
+
}),
|
|
83
|
+
release: (amount) => withLock(() => {
|
|
84
|
+
const now = read();
|
|
85
|
+
write(now - amount < 0n ? 0n : now - amount);
|
|
86
|
+
}),
|
|
87
|
+
spent: () => read(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* File-backed fee tally. Node-only, same atomic-rename pattern. `accrued` is ATOMIC x 1e6
|
|
94
|
+
* so sub-unit fees are not rounded away; `count` is payments since the last settlement.
|
|
95
|
+
*/
|
|
96
|
+
export function createFileFeeStore(path: string, opts: { lockTimeoutMs?: number } = {}) {
|
|
97
|
+
const lockPath = path + '.lock';
|
|
98
|
+
const timeout = opts.lockTimeoutMs ?? 5000;
|
|
99
|
+
|
|
100
|
+
const read = (): { accrued: bigint; count: bigint } => {
|
|
101
|
+
if (!existsSync(path)) return { accrued: 0n, count: 0n };
|
|
102
|
+
try {
|
|
103
|
+
const j = JSON.parse(readFileSync(path, 'utf8'));
|
|
104
|
+
return { accrued: BigInt(j.accruedScaled ?? '0'), count: BigInt(j.count ?? '0') };
|
|
105
|
+
} catch {
|
|
106
|
+
// Never read a corrupt tally as zero - that silently discards fees already owed.
|
|
107
|
+
throw new Error(`x402 fee tally is unreadable: ${path}. Refusing to treat it as zero.`);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const write = (v: { accrued: bigint; count: bigint }): void => {
|
|
112
|
+
const tmp = path + '.tmp';
|
|
113
|
+
writeFileSync(tmp, JSON.stringify({
|
|
114
|
+
accruedScaled: v.accrued.toString(), count: v.count.toString(),
|
|
115
|
+
updated: new Date().toISOString(),
|
|
116
|
+
}));
|
|
117
|
+
renameSync(tmp, path);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
async function withLock<T>(fn: () => T): Promise<T> {
|
|
121
|
+
const deadline = Date.now() + timeout;
|
|
122
|
+
let fd: number | undefined;
|
|
123
|
+
for (;;) {
|
|
124
|
+
try { fd = openSync(lockPath, 'wx'); break; } // O_CREAT|O_EXCL - one winner
|
|
125
|
+
catch {
|
|
126
|
+
if (Date.now() > deadline) throw new Error(`x402 fee lock timed out: ${lockPath}`);
|
|
127
|
+
await sleep(15);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
try { return fn(); }
|
|
131
|
+
finally {
|
|
132
|
+
try { closeSync(fd!); } catch { /* already closed */ }
|
|
133
|
+
try { unlinkSync(lockPath); } catch { /* already gone */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
get: async () => withLock(read),
|
|
139
|
+
set: async (v: { accrued: bigint; count: bigint }) => { await withLock(() => write(v)); },
|
|
140
|
+
/**
|
|
141
|
+
* Read, modify and write while HOLDING the lock. Separate get/set calls each take the
|
|
142
|
+
* lock and release it, so two processes can both read the same count and both write
|
|
143
|
+
* count+1 - one increment vanishes. Measured at 82% loss with eight concurrent writers,
|
|
144
|
+
* which shows up as the fee firing far less often than it is owed.
|
|
145
|
+
*/
|
|
146
|
+
update: async (fn: (cur: { accrued: bigint; count: bigint }) => { accrued: bigint; count: bigint }) =>
|
|
147
|
+
withLock(() => { const next = fn(read()); write(next); return next; }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* File-backed collector queue. Node-only. Keeps pending authorizations and the set of
|
|
153
|
+
* nonces ever seen, so a replay is rejected even across restarts.
|
|
154
|
+
*/
|
|
155
|
+
export function createFileCollectorStore(path: string) {
|
|
156
|
+
const read = () => {
|
|
157
|
+
if (!existsSync(path)) return { pending: [] as any[], seen: [] as string[] };
|
|
158
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
159
|
+
catch {
|
|
160
|
+
// Reading a corrupt queue as empty would forget owed fees AND re-open replay.
|
|
161
|
+
throw new Error(`x402 collector store is unreadable: ${path}. Refusing to treat it as empty.`);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const write = (d: any) => {
|
|
165
|
+
const tmp = path + '.tmp';
|
|
166
|
+
writeFileSync(tmp, JSON.stringify(d));
|
|
167
|
+
renameSync(tmp, path);
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
pending: async () => read().pending ?? [],
|
|
171
|
+
add: async (item: any) => { const d = read(); d.pending = [...(d.pending ?? []), item]; write(d); },
|
|
172
|
+
remove: async (nonces: string[]) => {
|
|
173
|
+
const d = read();
|
|
174
|
+
const drop = new Set(nonces.map(n => n.toLowerCase()));
|
|
175
|
+
d.pending = (d.pending ?? []).filter((i: any) => !drop.has(i.authorization.nonce.toLowerCase()));
|
|
176
|
+
write(d);
|
|
177
|
+
},
|
|
178
|
+
seen: async (nonce: string) => (read().seen ?? []).includes(nonce.toLowerCase()),
|
|
179
|
+
markSeen: async (nonce: string) => {
|
|
180
|
+
const d = read();
|
|
181
|
+
d.seen = [...(d.seen ?? []), nonce.toLowerCase()];
|
|
182
|
+
write(d);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* File-backed replay guard for the seller. Node-only.
|
|
189
|
+
*
|
|
190
|
+
* Remembers each settled authorization nonce only until it expires - after `validBefore`
|
|
191
|
+
* the authorization can never settle again, so the entry is prunable. Without that, a
|
|
192
|
+
* long-running seller's guard grows forever.
|
|
193
|
+
*/
|
|
194
|
+
export function createFileNonceStore(path: string) {
|
|
195
|
+
const read = (): Record<string, number> => {
|
|
196
|
+
if (!existsSync(path)) return {};
|
|
197
|
+
try { return JSON.parse(readFileSync(path, 'utf8')).nonces ?? {}; }
|
|
198
|
+
catch {
|
|
199
|
+
// Reading a corrupt guard as empty would re-open replay of every settled payment.
|
|
200
|
+
throw new Error(`x402 nonce store is unreadable: ${path}. Refusing to treat it as empty.`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const write = (n: Record<string, number>) => {
|
|
204
|
+
const tmp = path + '.tmp';
|
|
205
|
+
writeFileSync(tmp, JSON.stringify({ nonces: n, updated: new Date().toISOString() }));
|
|
206
|
+
renameSync(tmp, path);
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
seen: async (nonce: string): Promise<boolean> => {
|
|
210
|
+
const now = Math.floor(Date.now() / 1000);
|
|
211
|
+
const exp = read()[nonce.toLowerCase()];
|
|
212
|
+
return exp !== undefined && exp > now;
|
|
213
|
+
},
|
|
214
|
+
add: async (nonce: string, expiresAtUnix: number): Promise<void> => {
|
|
215
|
+
const now = Math.floor(Date.now() / 1000);
|
|
216
|
+
const n = read();
|
|
217
|
+
for (const [k, exp] of Object.entries(n)) if (exp <= now) delete n[k]; // prune
|
|
218
|
+
n[nonce.toLowerCase()] = expiresAtUnix;
|
|
219
|
+
write(n);
|
|
220
|
+
},
|
|
221
|
+
/** Entries currently held (after pruning expired ones). */
|
|
222
|
+
size: (): number => {
|
|
223
|
+
const now = Math.floor(Date.now() / 1000);
|
|
224
|
+
return Object.values(read()).filter(e => e > now).length;
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* File-backed tab ledger. Node-only, same atomic-rename and lock pattern as the fee tally.
|
|
231
|
+
*
|
|
232
|
+
* WHY THIS EXISTS. A tab is money a player has already paid. Holding that balance only in
|
|
233
|
+
* process memory means a deploy, a crash or a scale-down silently deletes credit somebody
|
|
234
|
+
* bought - and unlike a failed payment, nothing fails loudly when it happens.
|
|
235
|
+
*
|
|
236
|
+
* Single-instance only. If more than one game server can serve the same player, back the
|
|
237
|
+
* ledger with your database instead and hold a row lock across the read and the write - the
|
|
238
|
+
* `update` contract below is what that lock has to protect.
|
|
239
|
+
*/
|
|
240
|
+
export function createFileLedgerStore(path: string, opts: { lockTimeoutMs?: number } = {}) {
|
|
241
|
+
const lockPath = path + '.lock';
|
|
242
|
+
const timeout = opts.lockTimeoutMs ?? 5000;
|
|
243
|
+
|
|
244
|
+
type Row = {
|
|
245
|
+
playerAddress: string; remaining: string; opened: string;
|
|
246
|
+
spent: Array<{ id: string; at: number }>; updatedAt: string;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const readAll = (): Record<string, Row> => {
|
|
250
|
+
if (!existsSync(path)) return {};
|
|
251
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
252
|
+
catch {
|
|
253
|
+
// Reading a corrupt ledger as empty would erase every player's paid-for credit.
|
|
254
|
+
throw new Error(`x402 tab ledger unreadable: ${path} - fix or remove it deliberately`);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const writeAll = (all: Record<string, Row>): void => {
|
|
259
|
+
const tmp = path + '.tmp';
|
|
260
|
+
writeFileSync(tmp, JSON.stringify(all));
|
|
261
|
+
renameSync(tmp, path);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
async function withLock<T>(fn: () => T): Promise<T> {
|
|
265
|
+
const deadline = Date.now() + timeout;
|
|
266
|
+
let fd: number | undefined;
|
|
267
|
+
for (;;) {
|
|
268
|
+
try { fd = openSync(lockPath, 'wx'); break; }
|
|
269
|
+
catch {
|
|
270
|
+
if (Date.now() > deadline) throw new Error(`x402 tab ledger lock timed out: ${lockPath}`);
|
|
271
|
+
await sleep(15);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
try { return fn(); }
|
|
275
|
+
finally {
|
|
276
|
+
try { closeSync(fd!); } catch { /* already closed */ }
|
|
277
|
+
try { unlinkSync(lockPath); } catch { /* already gone */ }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Spent action ids exist to catch a client retry, which happens within seconds. Keeping
|
|
282
|
+
// them by AGE rather than by count means a long-lived tab cannot quietly forget an id and
|
|
283
|
+
// charge for it twice, and a busy one cannot grow without bound.
|
|
284
|
+
const KEEP_MS = 24 * 60 * 60 * 1000;
|
|
285
|
+
const prune = (spent: Array<{ id: string; at: number }>) => {
|
|
286
|
+
const cutoff = Date.now() - KEEP_MS;
|
|
287
|
+
const kept = spent.filter(e => e.at > cutoff);
|
|
288
|
+
return kept.length > 5000 ? kept.slice(-5000) : kept;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
get: async (playerId: string) => withLock(() => readAll()[playerId] ?? null),
|
|
293
|
+
set: async (playerId: string, s: any) => {
|
|
294
|
+
await withLock(() => {
|
|
295
|
+
const all = readAll();
|
|
296
|
+
all[playerId] = { ...s, spent: prune(s.spent ?? []) };
|
|
297
|
+
writeAll(all);
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
/**
|
|
301
|
+
* Read, modify and write while HOLDING the lock. Separate get/set calls each take and
|
|
302
|
+
* release it, so two writers can both read the same balance and both approve a spend it
|
|
303
|
+
* could only cover once.
|
|
304
|
+
*/
|
|
305
|
+
update: async (playerId: string, fn: (cur: any) => any) =>
|
|
306
|
+
withLock(() => {
|
|
307
|
+
const all = readAll();
|
|
308
|
+
const next = fn(all[playerId] ?? null);
|
|
309
|
+
all[playerId] = { ...next, spent: prune(next.spent ?? []) };
|
|
310
|
+
writeAll(all);
|
|
311
|
+
return all[playerId];
|
|
312
|
+
}),
|
|
313
|
+
};
|
|
314
|
+
}
|
package/src/evm-tx.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402-trinity/evm-tx - sign and submit EIP-1559 transactions, plus signature recovery.
|
|
3
|
+
*
|
|
4
|
+
* OPTIONAL server-side module. The buyer core never needs this: it produces signed
|
|
5
|
+
* authorizations and hands them over. This is for whoever SUBMITS them - a facilitator,
|
|
6
|
+
* a collector, or anything else that puts transactions on a chain.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies: RLP, EIP-1559 encoding and ecrecover are all inline.
|
|
9
|
+
*/
|
|
10
|
+
import { keccak256, toHex, fromHex, __internals } from './x402.ts';
|
|
11
|
+
|
|
12
|
+
type Jac = [bigint, bigint, bigint];
|
|
13
|
+
// Typed rather than `as any`: the curve constants must stay bigint or arithmetic below
|
|
14
|
+
// silently degrades to number and stops type-checking.
|
|
15
|
+
const {
|
|
16
|
+
toBig, beBytes, signWith, makeNonce, addressOf, jMul, jAdd, affine, G, N, P,
|
|
17
|
+
} = __internals as unknown as {
|
|
18
|
+
toBig: (b: Uint8Array) => bigint;
|
|
19
|
+
beBytes: (v: bigint, len: number) => Uint8Array;
|
|
20
|
+
signWith: (nonce: unknown, z: bigint, d: bigint) => string;
|
|
21
|
+
makeNonce: () => unknown;
|
|
22
|
+
addressOf: (d: bigint) => string;
|
|
23
|
+
jMul: (k: bigint, p: Jac) => Jac;
|
|
24
|
+
jAdd: (a: Jac, b: Jac) => Jac;
|
|
25
|
+
affine: (p: Jac) => [bigint, bigint];
|
|
26
|
+
G: Jac; N: bigint; P: bigint;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/* ------------------------------- RLP ------------------------------- */
|
|
30
|
+
|
|
31
|
+
const cat = (...a: Uint8Array[]): Uint8Array => {
|
|
32
|
+
const t = new Uint8Array(a.reduce((n, x) => n + x.length, 0));
|
|
33
|
+
let o = 0;
|
|
34
|
+
for (const x of a) { t.set(x, o); o += x.length; }
|
|
35
|
+
return t;
|
|
36
|
+
};
|
|
37
|
+
/** integer -> minimal big-endian bytes; zero is the empty string, per RLP */
|
|
38
|
+
const minimal = (v: bigint): Uint8Array => {
|
|
39
|
+
if (v === 0n) return new Uint8Array(0);
|
|
40
|
+
let h = v.toString(16);
|
|
41
|
+
if (h.length % 2) h = '0' + h;
|
|
42
|
+
return fromHex('0x' + h);
|
|
43
|
+
};
|
|
44
|
+
const rlpLen = (len: number, offset: number): Uint8Array => {
|
|
45
|
+
if (len < 56) return new Uint8Array([offset + len]);
|
|
46
|
+
const lb = minimal(BigInt(len));
|
|
47
|
+
return cat(new Uint8Array([offset + 55 + lb.length]), lb);
|
|
48
|
+
};
|
|
49
|
+
type RlpInput = Uint8Array | RlpInput[];
|
|
50
|
+
export function rlp(x: RlpInput): Uint8Array {
|
|
51
|
+
if (Array.isArray(x)) {
|
|
52
|
+
const payload = cat(...x.map(rlp));
|
|
53
|
+
return cat(rlpLen(payload.length, 0xc0), payload);
|
|
54
|
+
}
|
|
55
|
+
if (x.length === 1 && x[0] < 0x80) return x;
|
|
56
|
+
return cat(rlpLen(x.length, 0x80), x);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* --------------------------- ecrecover --------------------------- */
|
|
60
|
+
|
|
61
|
+
const mod = (a: bigint, m: bigint): bigint => { const r = a % m; return r < 0n ? r + m : r; };
|
|
62
|
+
const modPow = (b: bigint, e: bigint, m: bigint): bigint => {
|
|
63
|
+
let r = 1n; b = mod(b, m);
|
|
64
|
+
while (e > 0n) { if (e & 1n) r = mod(r * b, m); b = mod(b * b, m); e >>= 1n; }
|
|
65
|
+
return r;
|
|
66
|
+
};
|
|
67
|
+
const inv = (a: bigint, m: bigint): bigint => {
|
|
68
|
+
let r = m, nr = mod(a, m), s = 0n, ns = 1n;
|
|
69
|
+
while (nr !== 0n) { const q = r / nr; [r, nr] = [nr, r - q * nr]; [s, ns] = [ns, s - q * ns]; }
|
|
70
|
+
return mod(s, m);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Recover the signing address from a digest and a 65-byte r||s||v signature.
|
|
75
|
+
* This is exactly what EIP-3009 does on-chain, so it is the right local check before
|
|
76
|
+
* spending gas submitting something that would revert.
|
|
77
|
+
*/
|
|
78
|
+
export function recoverSigner(digest: bigint, signature: string): string | null {
|
|
79
|
+
try {
|
|
80
|
+
const sig = fromHex(signature);
|
|
81
|
+
if (sig.length !== 65) return null;
|
|
82
|
+
const r = toBig(sig.slice(0, 32)), s = toBig(sig.slice(32, 64)), v = sig[64] - 27;
|
|
83
|
+
if (r === 0n || r >= N || s === 0n || s >= N || v < 0 || v > 3) return null;
|
|
84
|
+
const x = r + (v >> 1 ? N : 0n);
|
|
85
|
+
if (x >= P) return null;
|
|
86
|
+
let y = modPow(mod(x * x * x + 7n, P), (P + 1n) / 4n, P);
|
|
87
|
+
if (mod(y * y, P) !== mod(x * x * x + 7n, P)) return null; // not on the curve
|
|
88
|
+
if ((y & 1n) !== BigInt(v & 1)) y = P - y;
|
|
89
|
+
const Q = jMul(inv(r, N), jAdd(jMul(s, [x, y, 1n]), jMul(mod(-digest, N), G)));
|
|
90
|
+
if (Q[2] === 0n) return null;
|
|
91
|
+
const xy = affine(Q);
|
|
92
|
+
return toHex(keccak256(beBytes(xy[0], 32), beBytes(xy[1], 32)).slice(12));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* --------------------------- JSON-RPC --------------------------- */
|
|
99
|
+
|
|
100
|
+
export interface RpcConfig { urls: string[]; timeoutMs?: number; retries?: number }
|
|
101
|
+
|
|
102
|
+
export function createRpc(cfg: RpcConfig) {
|
|
103
|
+
const urls = cfg.urls;
|
|
104
|
+
const timeoutMs = cfg.timeoutMs ?? 15000;
|
|
105
|
+
const retries = cfg.retries ?? 3;
|
|
106
|
+
if (!urls.length) throw new Error('evm-tx: at least one RPC url is required');
|
|
107
|
+
|
|
108
|
+
return async function rpc(method: string, params: unknown[] = []): Promise<any> {
|
|
109
|
+
let last: unknown;
|
|
110
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
111
|
+
for (const url of urls) {
|
|
112
|
+
try {
|
|
113
|
+
const r = await fetch(url, {
|
|
114
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
116
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
117
|
+
});
|
|
118
|
+
const j = await r.json();
|
|
119
|
+
if (j.error) {
|
|
120
|
+
const msg = String(j.error.message ?? '');
|
|
121
|
+
// Transient infrastructure problems are worth failing over for; a real
|
|
122
|
+
// contract revert is not - surface it immediately.
|
|
123
|
+
if (!/healthy|unavailable|rate|limit|timeout|busy|capacity/i.test(msg)) {
|
|
124
|
+
throw new Error(`${method}: ${msg}`);
|
|
125
|
+
}
|
|
126
|
+
last = new Error(`${method}: ${msg}`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
return j.result;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e instanceof Error && e.message.startsWith(method + ':')) throw e;
|
|
132
|
+
last = e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
await new Promise(r => setTimeout(r, 800 * (attempt + 1)));
|
|
136
|
+
}
|
|
137
|
+
throw last instanceof Error ? last : new Error(`${method}: all RPCs failed`);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ----------------------- transaction signing ----------------------- */
|
|
142
|
+
|
|
143
|
+
export interface TxRequest {
|
|
144
|
+
chainId: number;
|
|
145
|
+
to: string;
|
|
146
|
+
data: string;
|
|
147
|
+
gasLimit?: bigint;
|
|
148
|
+
value?: bigint;
|
|
149
|
+
/** Multiplier applied to the observed gas price for maxFeePerGas. Default 4. */
|
|
150
|
+
feeMultiplier?: bigint;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Sign and broadcast an EIP-1559 transaction.
|
|
155
|
+
*
|
|
156
|
+
* Re-submitting the SAME signed transaction is idempotent at the network layer: identical
|
|
157
|
+
* nonce plus identical signature yields an identical hash. That makes RPC failover safe here.
|
|
158
|
+
*/
|
|
159
|
+
export async function sendTransaction(
|
|
160
|
+
rpc: (m: string, p?: unknown[]) => Promise<any>,
|
|
161
|
+
privateKey: string,
|
|
162
|
+
tx: TxRequest,
|
|
163
|
+
): Promise<{ hash: string; from: string }> {
|
|
164
|
+
const d = toBig(fromHex(privateKey));
|
|
165
|
+
if (d === 0n || d >= N) throw new Error('evm-tx: invalid key material');
|
|
166
|
+
const from = addressOf(d);
|
|
167
|
+
|
|
168
|
+
const [nonceHex, gasPriceHex] = await Promise.all([
|
|
169
|
+
rpc('eth_getTransactionCount', [from, 'pending']),
|
|
170
|
+
rpc('eth_gasPrice', []),
|
|
171
|
+
]);
|
|
172
|
+
const tip = 1_000_000n; // 0.001 gwei
|
|
173
|
+
const maxFee = BigInt(gasPriceHex) * (tx.feeMultiplier ?? 4n) + tip;
|
|
174
|
+
|
|
175
|
+
const fields: RlpInput[] = [
|
|
176
|
+
minimal(BigInt(tx.chainId)), minimal(BigInt(nonceHex)), minimal(tip), minimal(maxFee),
|
|
177
|
+
minimal(tx.gasLimit ?? 200_000n), fromHex(tx.to), minimal(tx.value ?? 0n),
|
|
178
|
+
fromHex(tx.data), [],
|
|
179
|
+
];
|
|
180
|
+
const sigHash = toBig(keccak256(new Uint8Array([0x02]), rlp(fields)));
|
|
181
|
+
const sig = fromHex(signWith(makeNonce(), sigHash, d));
|
|
182
|
+
|
|
183
|
+
// Self-check before spending gas: if RLP or the signing hash were wrong this would not
|
|
184
|
+
// recover to us, and the node would reject or - worse - mis-attribute the transaction.
|
|
185
|
+
if (recoverSigner(sigHash, toHex(sig)) !== from) {
|
|
186
|
+
throw new Error('evm-tx: signed transaction does not recover to the sender; refusing to broadcast');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// r and s are fixed 32-byte values, but RLP integers must be MINIMAL: a leading zero
|
|
190
|
+
// byte makes them non-canonical and go-ethereum rejects the whole transaction with
|
|
191
|
+
// "rlp: non-canonical integer (leading zero bytes) for *big.Int". Either value starts
|
|
192
|
+
// with a zero byte roughly one time in 256, so this is invisible until it is not - it
|
|
193
|
+
// took seventy-one consecutive broadcasts to surface.
|
|
194
|
+
const trimZeros = (b: Uint8Array): Uint8Array => {
|
|
195
|
+
let i = 0;
|
|
196
|
+
while (i < b.length && b[i] === 0) i++;
|
|
197
|
+
return b.subarray(i);
|
|
198
|
+
};
|
|
199
|
+
const raw = toHex(cat(new Uint8Array([0x02]),
|
|
200
|
+
rlp([...fields, minimal(BigInt(sig[64] - 27)),
|
|
201
|
+
trimZeros(sig.slice(0, 32)), trimZeros(sig.slice(32, 64))])));
|
|
202
|
+
const hash = await rpc('eth_sendRawTransaction', [raw]);
|
|
203
|
+
return { hash, from };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function waitForReceipt(
|
|
207
|
+
rpc: (m: string, p?: unknown[]) => Promise<any>,
|
|
208
|
+
hash: string,
|
|
209
|
+
opts: { timeoutMs?: number; pollMs?: number } = {},
|
|
210
|
+
): Promise<{ ok: boolean; gasUsed: number; blockNumber: number } | null> {
|
|
211
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 120000);
|
|
212
|
+
const poll = opts.pollMs ?? 2500;
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
await new Promise(r => setTimeout(r, poll));
|
|
215
|
+
const rc = await rpc('eth_getTransactionReceipt', [hash]).catch(() => null);
|
|
216
|
+
if (rc) return { ok: rc.status === '0x1', gasUsed: Number(BigInt(rc.gasUsed)), blockNumber: Number(BigInt(rc.blockNumber)) };
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** abi.encode word: bigint, 0x-hex, or bytes. */
|
|
222
|
+
export const word = (v: bigint | string | Uint8Array): string => {
|
|
223
|
+
if (typeof v === 'bigint') return toHex(beBytes(v, 32)).slice(2);
|
|
224
|
+
const b = typeof v === 'string' ? fromHex(v) : v;
|
|
225
|
+
const w = new Uint8Array(32);
|
|
226
|
+
w.set(b, 32 - b.length);
|
|
227
|
+
return toHex(w).slice(2);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** 4-byte selector for a solidity signature. */
|
|
231
|
+
export const selector = (sig: string): string =>
|
|
232
|
+
toHex(keccak256(new TextEncoder().encode(sig))).slice(0, 10);
|
|
233
|
+
|
|
234
|
+
/** Calldata for USDC's transferWithAuthorization. */
|
|
235
|
+
export function transferWithAuthorizationData(
|
|
236
|
+
auth: { from: string; to: string; value: string; validAfter: string; validBefore: string; nonce: string },
|
|
237
|
+
signature: string,
|
|
238
|
+
): string {
|
|
239
|
+
const sig = fromHex(signature);
|
|
240
|
+
return selector('transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,uint8,bytes32,bytes32)')
|
|
241
|
+
+ word(auth.from) + word(auth.to) + word(BigInt(auth.value))
|
|
242
|
+
+ word(BigInt(auth.validAfter)) + word(BigInt(auth.validBefore)) + word(auth.nonce)
|
|
243
|
+
+ word(BigInt(sig[64])) + word(sig.slice(0, 32)) + word(sig.slice(32, 64));
|
|
244
|
+
}
|