@ninsei-labs/sdk 0.39.0 → 0.39.2

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.
@@ -1,345 +1,345 @@
1
- // A TEST EVM WALLET FOR NODE: KEY FROM THE ENVIRONMENT, REFUSAL ON A PRODUCTION NETWORK.
2
- //
3
- // WHY A SEPARATE ADAPTER. The SDK had only the browser wallet (evm-wallet.mjs): it receives the page's ready-made
4
- // EIP-1193 provider and signs via eth_sendTransaction - that is, the person's wallet does the signing. From Node
5
- // there is nothing to sign with, and a live run on a local chain was impossible: the SDK refused at the very first
6
- // step that needed a signature. This file closes exactly that hole - and only it.
7
- //
8
- // THIS IS A TOOL OF THE TEST CONTOUR, NOT A USER WALLET. The name shows it (node-test-*), and from that come two
9
- // rules that are not up for discussion here:
10
- //
11
- // 1) THE KEY COMES ONLY FROM THE ENVIRONMENT OR FROM A FILE, the path to which also lives in the environment.
12
- // This module does NOT read command-line arguments AT ALL: a key in argv is visible in the shell history and
13
- // in the process list, that is, it leaks before anything is signed. The key's value is never printed: only the
14
- // address, transaction hashes and amounts go out.
15
- //
16
- // 2) ON A PRODUCTION NETWORK THE ADAPTER DOES NOT SIGN. The rule is not its own: it is already declared by the
17
- // engine (sdk/src/swap-flow.mjs: production = chain.escrow.mode === "live" && !chain.testnet) and the page
18
- // itself uses the same marker. A private "production network" marker would silently drift from the engine, and
19
- // then the test key would sign real money. So the rule is taken from the network registry, not written here.
20
- //
21
- // DEPENDENCIES. The signature is assembled the same way as the provider node's (rfq/evmSend.mjs): @noble/curves
22
- // (secp256k1) and keccak256 - they are in the repository's devDependencies and already used in the project. There
23
- // is neither DOM nor node:fs at the top level here: the adapters directory is the only place where the environment
24
- // is allowed, but a static node:fs import would break the package's browser build, so the file is read via a
25
- // dynamic import and only inside the key-loading function.
26
- import { secp256k1 } from "@noble/curves/secp256k1.js";
27
- import keccakPkg from "keccak256";
28
- import { SdkError } from "../errors.mjs";
29
- import { config as engineConfig } from "../engine.mjs";
30
-
31
- const keccak256 = (bytes) => new Uint8Array(keccakPkg(Buffer.from(bytes)));
32
-
33
- // THE ENVIRONMENT VARIABLE NAMES - HERE, IN ONE PLACE: the run and the adapter must look at the same name,
34
- // otherwise the key "is there" but the adapter does not see it.
35
- export const KEY_ENV = "ARRAKIS_TEST_EVM_KEY";
36
- export const KEY_FILE_ENV = "ARRAKIS_TEST_EVM_KEY_FILE";
37
- export const RPC_ENV = "ARRAKIS_TEST_EVM_RPC";
38
- export const CHAIN_ENV = "ARRAKIS_TEST_EVM_CHAIN";
39
- export const CLAIMER_ENV = "ARRAKIS_TEST_EVM_CLAIMER";
40
-
41
- // AN EXPLICIT WHITELIST OF TEST NETWORKS - HERE, NOT IN THE ENGINE, and WHY. The engine's "production network" rule
42
- // (sdk/src/swap-flow.mjs: escrow.mode === "live" && !testnet) today does not trigger ON ANY recorded network: for
43
- // the registry's production networks the escrow is marked "simulated", that is, by the letter of the rule they are
44
- // not production. So the test key would also sign on mainnet - the engine would not notice, and the engine's rule
45
- // must not be changed (it is shared by the page and all legs). So the "signing is ALLOWED here" marker is set
46
- // separately and EXPLICITLY: only what is listed, and nothing more. First - the local anvil by chainId 31337.
47
- export const TEST_CHAIN_IDS = Object.freeze([31337]);
48
-
49
- const envOf = (name) => {
50
- const box = typeof process !== "undefined" && process ? process.env : null;
51
- const value = box ? box[name] : undefined;
52
- return typeof value === "string" && value.trim() ? value.trim() : null;
53
- };
54
- export const envValue = envOf;
55
-
56
- // --- ENCODINGS -----------------------------------------------------------------------------------------
57
- const hexToBytes = (hex) => {
58
- const h = String(hex || "").replace(/^0x/, "");
59
- if (h.length === 0) return new Uint8Array(0);
60
- if (h.length % 2) throw new SdkError("bad-input", { field: "hex", why: "odd" });
61
- if (!/^[0-9a-fA-F]+$/.test(h)) throw new SdkError("bad-input", { field: "hex", why: "not-hex" });
62
- return Uint8Array.from(Buffer.from(h, "hex"));
63
- };
64
- const bytesToHex = (bytes) => "0x" + Buffer.from(bytes).toString("hex");
65
- // Integers in RLP are encoded MINIMALLY, and zero is the empty string. A non-canonical encoding (an extra leading
66
- // zero) makes the node reject the WHOLE transaction, even though the signature is valid.
67
- const numToBytes = (value) => {
68
- const v = BigInt(value);
69
- if (v < 0n) throw new SdkError("bad-input", { field: "value", why: "negative" });
70
- if (v === 0n) return new Uint8Array(0);
71
- let hex = v.toString(16);
72
- if (hex.length % 2) hex = "0" + hex;
73
- return hexToBytes(hex);
74
- };
75
- const intFromBytes = (bytes) => numToBytes(BigInt(bytesToHex(bytes)));
76
- const encodeLength = (len, offset) => {
77
- if (len < 56) return Uint8Array.from([offset + len]);
78
- const lenBytes = numToBytes(len);
79
- return Uint8Array.from([offset + 55 + lenBytes.length, ...lenBytes]);
80
- };
81
- const rlpItem = (item) => {
82
- if (item instanceof Uint8Array) {
83
- if (item.length === 1 && item[0] < 0x80) return item;
84
- return Uint8Array.from([...encodeLength(item.length, 0x80), ...item]);
85
- }
86
- if (Array.isArray(item)) {
87
- const body = item.flatMap((x) => [...rlpItem(x)]);
88
- return Uint8Array.from([...encodeLength(body.length, 0xc0), ...body]);
89
- }
90
- throw new SdkError("bad-input", { field: "rlp" });
91
- };
92
-
93
- /** The account address for a private key: the last 20 bytes of the keccak256 of the UNcompressed point. */
94
- export function addressOfPrivateKey(privateKeyHex) {
95
- const pub = secp256k1.getPublicKey(hexToBytes(privateKeyHex), false);
96
- return "0x" + Buffer.from(keccak256(pub.slice(1))).toString("hex").slice(-40);
97
- }
98
-
99
- /**
100
- * SIGNING A LEGACY TRANSACTION (type 0, EIP-155). Legacy specifically, not 1559: it has no dependency on fee
101
- * history, and a local chain accepts it everywhere. Three things that are easy to get silently wrong (all three
102
- * were already caught in rfq/evmSend.mjs by checking against the Foundry reference, and all three are repeated
103
- * here deliberately):
104
- * prehash: false - Ethereum signs the ALREADY computed keccak hash; the library by default hashes the passed
105
- * data once more, and the signature becomes valid in form and completely foreign;
106
- * recovery is the FIRST byte of the 65-byte signature (not the last);
107
- * r and s go into RLP with minimal encoding, not full 32 bytes.
108
- */
109
- export function signLegacyTx({ privateKey, nonce, gasPrice, gasLimit, to, data = "0x", value = 0, chainId }) {
110
- const signing = rlpItem([
111
- numToBytes(nonce), numToBytes(gasPrice), numToBytes(gasLimit), hexToBytes(to),
112
- numToBytes(value), hexToBytes(data), numToBytes(chainId), new Uint8Array(0), new Uint8Array(0),
113
- ]);
114
- const digest = keccak256(signing);
115
- const sig = secp256k1.sign(digest, hexToBytes(privateKey), { format: "recovered", prehash: false });
116
- if (sig.length !== 65) throw new SdkError("bad-input", { field: "signature", got: sig.length });
117
- const recovery = Number(sig[0]);
118
- if (recovery !== 0 && recovery !== 1) throw new SdkError("bad-input", { field: "signature", why: "recovery" });
119
- const r = intFromBytes(sig.slice(1, 33));
120
- const s = intFromBytes(sig.slice(33, 65));
121
- const v = BigInt(recovery) + 35n + BigInt(chainId) * 2n;
122
- const raw = rlpItem([
123
- numToBytes(nonce), numToBytes(gasPrice), numToBytes(gasLimit), hexToBytes(to),
124
- numToBytes(value), hexToBytes(data), numToBytes(v), r, s,
125
- ]);
126
- return { raw: bytesToHex(raw), hash: bytesToHex(keccak256(raw)) };
127
- }
128
-
129
- // --- LOADING THE KEY ------------------------------------------------------------------------------------
130
- /**
131
- * The key from the environment or from a file. THE LOADER LIVES NEXT TO THE ADAPTER ON PURPOSE: it is the only
132
- * entry point for the key, and if the caller could pass the key as an argument, the rule "never from argv" would
133
- * rest on an honest word.
134
- * Returns { privateKey, address, from } - from names the source ("env" or "file"), the key itself goes out only in
135
- * this field and is never printed anywhere.
136
- */
137
- export async function testPrivateKeyFromEnv() {
138
- let raw = envOf(KEY_ENV);
139
- let from = "env";
140
- if (!raw) {
141
- const file = envOf(KEY_FILE_ENV);
142
- if (!file) throw new SdkError("bad-input", { field: "privateKey", missing: [KEY_ENV, KEY_FILE_ENV] });
143
- // DYNAMIC IMPORT: a static node:fs would end up in the package's browser build, though it is needed only here.
144
- const fs = await import("node:fs");
145
- raw = String(fs.readFileSync(file, "utf8")).trim().split(/\r?\n/)[0].trim();
146
- from = "file";
147
- }
148
- const hex = raw.replace(/^0x/, "");
149
- if (!/^[0-9a-fA-F]{64}$/.test(hex)) throw new SdkError("bad-input", { field: "privateKey", why: "shape" });
150
- return { privateKey: "0x" + hex.toLowerCase(), address: addressOfPrivateKey(hex), from };
151
- }
152
-
153
- // --- A READ PROVIDER ------------------------------------------------------------------------------------
154
- /**
155
- * An EIP-1193-compatible provider over HTTP JSON-RPC. Needed where the SDK reads the chain with the leg driver
156
- * (leg.balances -> eth_getBalance/eth_call) and as the reader for the lock step. THERE IS DELIBERATELY NO SIGNING
157
- * HERE: eth_sendTransaction answers with a refusal, because this adapter must sign with its own key, not the node -
158
- * otherwise the key would end up with the node, and the rule "key only from the environment" would stop meaning
159
- * anything.
160
- */
161
- export function evmRpcProvider(url) {
162
- if (typeof url !== "string" || !/^https?:\/\//.test(url)) throw new SdkError("bad-input", { field: "rpcUrl" });
163
- const call = async (method, params = []) => {
164
- const res = await fetch(url, {
165
- method: "POST",
166
- headers: { "content-type": "application/json" },
167
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
168
- });
169
- if (!res.ok) throw new SdkError("server-unavailable", { rpc: method, status: res.status });
170
- const body = await res.json();
171
- if (body && body.error) throw new SdkError("server-unavailable", { rpc: method, why: String(body.error.message || "").slice(0, 80) });
172
- return body ? body.result : null;
173
- };
174
- const provider = {
175
- url,
176
- request({ method, params = [] }) {
177
- if (method === "eth_sendTransaction") {
178
- throw new SdkError("not-implemented", { surface: "provider.eth_sendTransaction" });
179
- }
180
- if (method === "eth_requestAccounts") return Promise.resolve([]);
181
- return call(method, params);
182
- },
183
- };
184
- return provider;
185
- }
186
-
187
- /**
188
- * CONTRACT READ FOR THE LOCK SEAM: { to, data } -> hex. Exactly the shape the engine expects (www/js/evm/
189
- * session.js, readContract), so it is dropped into the SDK WITHOUT an adapter. This is a CHAIN READ, not a node
190
- * substitution: the calls go to the real node at the rpcUrl address.
191
- */
192
- export function evmRpcReader({ url }) {
193
- const provider = evmRpcProvider(url);
194
- return ({ to, data } = {}) => {
195
- if (!to || !data) throw new SdkError("bad-input", { field: "eth_call" });
196
- return provider.request({ method: "eth_call", params: [{ to, data }, "latest"] });
197
- };
198
- }
199
-
200
- // --- THE PRODUCTION-NETWORK MARKER ----------------------------------------------------------------------
201
- /**
202
- * A PRODUCTION NETWORK - BY THE ENGINE'S RULE, NOT ITS OWN. The marker is taken from www/js/core/config.js (the
203
- * testnet field) and sdk/src/swap-flow.mjs (escrow.mode === "live"): on a production network there is a live
204
- * escrow and NO testnet marker. An unknown network is also considered production: there is nothing to prove it is a
205
- * test one, and an error in that direction costs a test key signing over real money.
206
- */
207
- export function isProductionChain(chainId) {
208
- const chain = typeof engineConfig.chainByChainId === "function" ? engineConfig.chainByChainId(Number(chainId)) : null;
209
- if (!chain) return { production: true, reason: "unknown-chain", chain: null };
210
- const escrow = chain.escrow && typeof chain.escrow === "object" ? chain.escrow : null;
211
- const production = Boolean(escrow && escrow.mode === "live" && !chain.testnet);
212
- return { production, reason: production ? "production" : "testnet", chain };
213
- }
214
-
215
- // --- THE ADAPTER ----------------------------------------------------------------------------------------
216
- /**
217
- * A test EVM wallet: the same interface as the page's wallet (vm, connect, disconnect, address, chainId, subscribe,
218
- * driver + send/receipt), but the signature is its own and the chain is read directly.
219
- *
220
- * IT ACCEPTS ONLY AN ALREADY-LOADED KEY: the loader is testPrivateKeyFromEnv above, and there is no other entry.
221
- * rpcUrl and chainId come from outside, but as VALUES (not the key), and the adapter keeps no factory address at
222
- * all: the addresses come from the SDK/engine's network registry, and a second source of truth about addresses must
223
- * not be introduced.
224
- */
225
- export function nodeTestEvmWallet({ privateKey, rpcUrl, chainId = null, id = "node-test" } = {}) {
226
- const hex = String(privateKey || "").replace(/^0x/, "");
227
- if (!/^[0-9a-fA-F]{64}$/.test(hex)) throw new SdkError("bad-input", { field: "privateKey" });
228
- const key = "0x" + hex.toLowerCase();
229
- const owner = addressOfPrivateKey(key);
230
- const provider = evmRpcProvider(rpcUrl);
231
- let connected = false;
232
-
233
- const remoteChainId = async () => Number(BigInt(await provider.request({ method: "eth_chainId" })));
234
-
235
- // REFUSAL ON A PRODUCTION NETWORK - ONE POINT THROUGH WHICH EVERY SIGNATURE PASSES. The check stands here, not in
236
- // connect(), deliberately: connect may happen once and early, while the node's network may change later (or turn
237
- // out to be the wrong one at the moment of signing). We ask the network OF THE NODE right before signing.
238
- const assertTestContour = async () => {
239
- const remote = await remoteChainId();
240
- // A NETWORK OUTSIDE THE WHITELIST IS A REFUSAL NAMING THE NETWORK, BEFORE ANY SIGNATURE. This is the FIRST line
241
- // of defense and it does not depend on how the network is marked in the registry (the production marker is the
242
- // second line below, and today it may not trigger).
243
- if (!TEST_CHAIN_IDS.includes(Number(remote))) {
244
- throw new SdkError("wrong-chain", {
245
- why: "test-contour-network-not-allowed", chainId: remote, allowed: [...TEST_CHAIN_IDS],
246
- });
247
- }
248
- // THE SECOND LINE - THE ENGINE'S PRODUCTION-NETWORK RULE (not rewritten here, taken from the registry). It stays
249
- // for the case where a network from the list is later marked production: then the signature still stops.
250
- const view = isProductionChain(remote);
251
- if (view.production) {
252
- throw new SdkError("wrong-chain", {
253
- why: "test-contour-refuses-live-network", chainId: remote, reason: view.reason,
254
- escrowMode: view.chain && view.chain.escrow ? view.chain.escrow.mode : null,
255
- testnet: view.chain ? Boolean(view.chain.testnet) : null,
256
- });
257
- }
258
- if (chainId !== null && Number(chainId) !== remote) {
259
- throw new SdkError("wrong-chain", { got: remote, want: Number(chainId) });
260
- }
261
- return remote;
262
- };
263
-
264
- return {
265
- vm: "evm",
266
- id,
267
- // The address going out is what is anyway visible in every transaction. The key is not handed out by any method.
268
- async connect(options = {}) {
269
- const want = options && options.chainId !== undefined && options.chainId !== null ? Number(options.chainId) : chainId;
270
- const remote = await remoteChainId();
271
- if (want !== null && want !== undefined && Number(want) !== remote) {
272
- throw new SdkError("wrong-chain", { got: remote, want: Number(want) });
273
- }
274
- connected = true;
275
- },
276
- async disconnect() {
277
- connected = false;
278
- },
279
- address() {
280
- return owner;
281
- },
282
- async chainId() {
283
- return remoteChainId();
284
- },
285
- // NO SUBSCRIPTION: a local run has no "wallet changed network" event, while a poll timer would keep the process
286
- // alive after the last transaction. We return an unsubscribe function, as the adapter interface requires.
287
- subscribe() {
288
- return () => {};
289
- },
290
- driver() {
291
- return provider;
292
- },
293
- /**
294
- * SENDING: sign with its own key and eth_sendRawTransaction. The nonce is taken by pending, the gas price - from
295
- * the node, the gas limit - from the caller (the lock passes its own; if it did not, we estimate with a margin).
296
- * None of these numbers is invented: a guessed gas limit costs a signature, and a guessed nonce costs a lost
297
- * transaction.
298
- */
299
- async send({ to, data = "0x", value = 0n, gas = null }) {
300
- if (!connected) throw new SdkError("wallet-not-connected");
301
- if (typeof to !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(to)) throw new SdkError("bad-input", { field: "send.to" });
302
- const remote = await assertTestContour();
303
- const valueWei = BigInt(value === undefined || value === null ? 0 : value);
304
- const nonce = Number(BigInt(await provider.request({ method: "eth_getTransactionCount", params: [owner, "pending"] })));
305
- const gasPrice = BigInt(await provider.request({ method: "eth_gasPrice" }));
306
- let gasLimit = gas === null || gas === undefined ? null : BigInt(gas);
307
- if (gasLimit === null) {
308
- const est = BigInt(await provider.request({
309
- method: "eth_estimateGas",
310
- params: [{ from: owner, to, value: "0x" + valueWei.toString(16), data }],
311
- }));
312
- gasLimit = (est * 12n) / 10n;
313
- }
314
- const signed = signLegacyTx({
315
- privateKey: key, nonce, gasPrice, gasLimit, to, data,
316
- value: valueWei, chainId: remote,
317
- });
318
- const hash = await provider.request({ method: "eth_sendRawTransaction", params: [signed.raw] });
319
- if (typeof hash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(hash)) {
320
- throw new SdkError("server-unavailable", { step: "send-raw" });
321
- }
322
- // THE HASH IS CHECKED AGAINST THE LOCALLY COMPUTED ONE. A discrepancy means the node accepted a transaction
323
- // different from the one we signed - and that must be found out here, not from missing money.
324
- if (hash.toLowerCase() !== signed.hash.toLowerCase()) {
325
- throw new SdkError("contract-reverted", { step: "hash-mismatch", node: hash, local: signed.hash });
326
- }
327
- return hash;
328
- },
329
- /**
330
- * THE RECEIPT: asked once and returned as is (null - not in a block yet). THE ARGUMENT SHAPE IS ACCEPTED BOTH
331
- * WAYS: a hash string and an object { hash }. This is not "just in case": the adapter contract declares
332
- * receipt(txHash: string), while the lock path in the SDK calls the receipt as receipt({ hash }) - the engine's
333
- * waitReceipt passes an object (www/js/evm/funding.js), and lock.mjs passes it into the adapter without
334
- * normalisation. A strict adapter answers bad-input to this, the refusal is swallowed by the wait, and the
335
- * escrow address is silently replaced with the PREDICTED one - that is, the "address from the receipt" check
336
- * stops working without saying so.
337
- */
338
- async receipt(txHash) {
339
- const hash = typeof txHash === "string" ? txHash : (txHash && typeof txHash.hash === "string" ? txHash.hash : null);
340
- if (!hash) throw new SdkError("bad-input", { field: "receipt.txHash" });
341
- const r = await provider.request({ method: "eth_getTransactionReceipt", params: [hash] });
342
- return r || null;
343
- },
344
- };
345
- }
1
+ // A TEST EVM WALLET FOR NODE: KEY FROM THE ENVIRONMENT, REFUSAL ON A PRODUCTION NETWORK.
2
+ //
3
+ // WHY A SEPARATE ADAPTER. The SDK had only the browser wallet (evm-wallet.mjs): it receives the page's ready-made
4
+ // EIP-1193 provider and signs via eth_sendTransaction - that is, the person's wallet does the signing. From Node
5
+ // there is nothing to sign with, and a live run on a local chain was impossible: the SDK refused at the very first
6
+ // step that needed a signature. This file closes exactly that hole - and only it.
7
+ //
8
+ // THIS IS A TOOL OF THE TEST CONTOUR, NOT A USER WALLET. The name shows it (node-test-*), and from that come two
9
+ // rules that are not up for discussion here:
10
+ //
11
+ // 1) THE KEY COMES ONLY FROM THE ENVIRONMENT OR FROM A FILE, the path to which also lives in the environment.
12
+ // This module does NOT read command-line arguments AT ALL: a key in argv is visible in the shell history and
13
+ // in the process list, that is, it leaks before anything is signed. The key's value is never printed: only the
14
+ // address, transaction hashes and amounts go out.
15
+ //
16
+ // 2) ON A PRODUCTION NETWORK THE ADAPTER DOES NOT SIGN. The rule is not its own: it is already declared by the
17
+ // engine (sdk/src/swap-flow.mjs: production = chain.escrow.mode === "live" && !chain.testnet) and the page
18
+ // itself uses the same marker. A private "production network" marker would silently drift from the engine, and
19
+ // then the test key would sign real money. So the rule is taken from the network registry, not written here.
20
+ //
21
+ // DEPENDENCIES. The signature is assembled the same way as the provider node's (rfq/evmSend.mjs): @noble/curves
22
+ // (secp256k1) and keccak256 - they are in the repository's devDependencies and already used in the project. There
23
+ // is neither DOM nor node:fs at the top level here: the adapters directory is the only place where the environment
24
+ // is allowed, but a static node:fs import would break the package's browser build, so the file is read via a
25
+ // dynamic import and only inside the key-loading function.
26
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
27
+ import keccakPkg from "keccak256";
28
+ import { SdkError } from "../errors.mjs";
29
+ import { config as engineConfig } from "../engine.mjs";
30
+
31
+ const keccak256 = (bytes) => new Uint8Array(keccakPkg(Buffer.from(bytes)));
32
+
33
+ // THE ENVIRONMENT VARIABLE NAMES - HERE, IN ONE PLACE: the run and the adapter must look at the same name,
34
+ // otherwise the key "is there" but the adapter does not see it.
35
+ export const KEY_ENV = "ARRAKIS_TEST_EVM_KEY";
36
+ export const KEY_FILE_ENV = "ARRAKIS_TEST_EVM_KEY_FILE";
37
+ export const RPC_ENV = "ARRAKIS_TEST_EVM_RPC";
38
+ export const CHAIN_ENV = "ARRAKIS_TEST_EVM_CHAIN";
39
+ export const CLAIMER_ENV = "ARRAKIS_TEST_EVM_CLAIMER";
40
+
41
+ // AN EXPLICIT WHITELIST OF TEST NETWORKS - HERE, NOT IN THE ENGINE, and WHY. The engine's "production network" rule
42
+ // (sdk/src/swap-flow.mjs: escrow.mode === "live" && !testnet) today does not trigger ON ANY recorded network: for
43
+ // the registry's production networks the escrow is marked "simulated", that is, by the letter of the rule they are
44
+ // not production. So the test key would also sign on mainnet - the engine would not notice, and the engine's rule
45
+ // must not be changed (it is shared by the page and all legs). So the "signing is ALLOWED here" marker is set
46
+ // separately and EXPLICITLY: only what is listed, and nothing more. First - the local anvil by chainId 31337.
47
+ export const TEST_CHAIN_IDS = Object.freeze([31337]);
48
+
49
+ const envOf = (name) => {
50
+ const box = typeof process !== "undefined" && process ? process.env : null;
51
+ const value = box ? box[name] : undefined;
52
+ return typeof value === "string" && value.trim() ? value.trim() : null;
53
+ };
54
+ export const envValue = envOf;
55
+
56
+ // --- ENCODINGS -----------------------------------------------------------------------------------------
57
+ const hexToBytes = (hex) => {
58
+ const h = String(hex || "").replace(/^0x/, "");
59
+ if (h.length === 0) return new Uint8Array(0);
60
+ if (h.length % 2) throw new SdkError("bad-input", { field: "hex", why: "odd" });
61
+ if (!/^[0-9a-fA-F]+$/.test(h)) throw new SdkError("bad-input", { field: "hex", why: "not-hex" });
62
+ return Uint8Array.from(Buffer.from(h, "hex"));
63
+ };
64
+ const bytesToHex = (bytes) => "0x" + Buffer.from(bytes).toString("hex");
65
+ // Integers in RLP are encoded MINIMALLY, and zero is the empty string. A non-canonical encoding (an extra leading
66
+ // zero) makes the node reject the WHOLE transaction, even though the signature is valid.
67
+ const numToBytes = (value) => {
68
+ const v = BigInt(value);
69
+ if (v < 0n) throw new SdkError("bad-input", { field: "value", why: "negative" });
70
+ if (v === 0n) return new Uint8Array(0);
71
+ let hex = v.toString(16);
72
+ if (hex.length % 2) hex = "0" + hex;
73
+ return hexToBytes(hex);
74
+ };
75
+ const intFromBytes = (bytes) => numToBytes(BigInt(bytesToHex(bytes)));
76
+ const encodeLength = (len, offset) => {
77
+ if (len < 56) return Uint8Array.from([offset + len]);
78
+ const lenBytes = numToBytes(len);
79
+ return Uint8Array.from([offset + 55 + lenBytes.length, ...lenBytes]);
80
+ };
81
+ const rlpItem = (item) => {
82
+ if (item instanceof Uint8Array) {
83
+ if (item.length === 1 && item[0] < 0x80) return item;
84
+ return Uint8Array.from([...encodeLength(item.length, 0x80), ...item]);
85
+ }
86
+ if (Array.isArray(item)) {
87
+ const body = item.flatMap((x) => [...rlpItem(x)]);
88
+ return Uint8Array.from([...encodeLength(body.length, 0xc0), ...body]);
89
+ }
90
+ throw new SdkError("bad-input", { field: "rlp" });
91
+ };
92
+
93
+ /** The account address for a private key: the last 20 bytes of the keccak256 of the UNcompressed point. */
94
+ export function addressOfPrivateKey(privateKeyHex) {
95
+ const pub = secp256k1.getPublicKey(hexToBytes(privateKeyHex), false);
96
+ return "0x" + Buffer.from(keccak256(pub.slice(1))).toString("hex").slice(-40);
97
+ }
98
+
99
+ /**
100
+ * SIGNING A LEGACY TRANSACTION (type 0, EIP-155). Legacy specifically, not 1559: it has no dependency on fee
101
+ * history, and a local chain accepts it everywhere. Three things that are easy to get silently wrong (all three
102
+ * were already caught in rfq/evmSend.mjs by checking against the Foundry reference, and all three are repeated
103
+ * here deliberately):
104
+ * prehash: false - Ethereum signs the ALREADY computed keccak hash; the library by default hashes the passed
105
+ * data once more, and the signature becomes valid in form and completely foreign;
106
+ * recovery is the FIRST byte of the 65-byte signature (not the last);
107
+ * r and s go into RLP with minimal encoding, not full 32 bytes.
108
+ */
109
+ export function signLegacyTx({ privateKey, nonce, gasPrice, gasLimit, to, data = "0x", value = 0, chainId }) {
110
+ const signing = rlpItem([
111
+ numToBytes(nonce), numToBytes(gasPrice), numToBytes(gasLimit), hexToBytes(to),
112
+ numToBytes(value), hexToBytes(data), numToBytes(chainId), new Uint8Array(0), new Uint8Array(0),
113
+ ]);
114
+ const digest = keccak256(signing);
115
+ const sig = secp256k1.sign(digest, hexToBytes(privateKey), { format: "recovered", prehash: false });
116
+ if (sig.length !== 65) throw new SdkError("bad-input", { field: "signature", got: sig.length });
117
+ const recovery = Number(sig[0]);
118
+ if (recovery !== 0 && recovery !== 1) throw new SdkError("bad-input", { field: "signature", why: "recovery" });
119
+ const r = intFromBytes(sig.slice(1, 33));
120
+ const s = intFromBytes(sig.slice(33, 65));
121
+ const v = BigInt(recovery) + 35n + BigInt(chainId) * 2n;
122
+ const raw = rlpItem([
123
+ numToBytes(nonce), numToBytes(gasPrice), numToBytes(gasLimit), hexToBytes(to),
124
+ numToBytes(value), hexToBytes(data), numToBytes(v), r, s,
125
+ ]);
126
+ return { raw: bytesToHex(raw), hash: bytesToHex(keccak256(raw)) };
127
+ }
128
+
129
+ // --- LOADING THE KEY ------------------------------------------------------------------------------------
130
+ /**
131
+ * The key from the environment or from a file. THE LOADER LIVES NEXT TO THE ADAPTER ON PURPOSE: it is the only
132
+ * entry point for the key, and if the caller could pass the key as an argument, the rule "never from argv" would
133
+ * rest on an honest word.
134
+ * Returns { privateKey, address, from } - from names the source ("env" or "file"), the key itself goes out only in
135
+ * this field and is never printed anywhere.
136
+ */
137
+ export async function testPrivateKeyFromEnv() {
138
+ let raw = envOf(KEY_ENV);
139
+ let from = "env";
140
+ if (!raw) {
141
+ const file = envOf(KEY_FILE_ENV);
142
+ if (!file) throw new SdkError("bad-input", { field: "privateKey", missing: [KEY_ENV, KEY_FILE_ENV] });
143
+ // DYNAMIC IMPORT: a static node:fs would end up in the package's browser build, though it is needed only here.
144
+ const fs = await import("node:fs");
145
+ raw = String(fs.readFileSync(file, "utf8")).trim().split(/\r?\n/)[0].trim();
146
+ from = "file";
147
+ }
148
+ const hex = raw.replace(/^0x/, "");
149
+ if (!/^[0-9a-fA-F]{64}$/.test(hex)) throw new SdkError("bad-input", { field: "privateKey", why: "shape" });
150
+ return { privateKey: "0x" + hex.toLowerCase(), address: addressOfPrivateKey(hex), from };
151
+ }
152
+
153
+ // --- A READ PROVIDER ------------------------------------------------------------------------------------
154
+ /**
155
+ * An EIP-1193-compatible provider over HTTP JSON-RPC. Needed where the SDK reads the chain with the leg driver
156
+ * (leg.balances -> eth_getBalance/eth_call) and as the reader for the lock step. THERE IS DELIBERATELY NO SIGNING
157
+ * HERE: eth_sendTransaction answers with a refusal, because this adapter must sign with its own key, not the node -
158
+ * otherwise the key would end up with the node, and the rule "key only from the environment" would stop meaning
159
+ * anything.
160
+ */
161
+ export function evmRpcProvider(url) {
162
+ if (typeof url !== "string" || !/^https?:\/\//.test(url)) throw new SdkError("bad-input", { field: "rpcUrl" });
163
+ const call = async (method, params = []) => {
164
+ const res = await fetch(url, {
165
+ method: "POST",
166
+ headers: { "content-type": "application/json" },
167
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
168
+ });
169
+ if (!res.ok) throw new SdkError("server-unavailable", { rpc: method, status: res.status });
170
+ const body = await res.json();
171
+ if (body && body.error) throw new SdkError("server-unavailable", { rpc: method, why: String(body.error.message || "").slice(0, 80) });
172
+ return body ? body.result : null;
173
+ };
174
+ const provider = {
175
+ url,
176
+ request({ method, params = [] }) {
177
+ if (method === "eth_sendTransaction") {
178
+ throw new SdkError("not-implemented", { surface: "provider.eth_sendTransaction" });
179
+ }
180
+ if (method === "eth_requestAccounts") return Promise.resolve([]);
181
+ return call(method, params);
182
+ },
183
+ };
184
+ return provider;
185
+ }
186
+
187
+ /**
188
+ * CONTRACT READ FOR THE LOCK SEAM: { to, data } -> hex. Exactly the shape the engine expects (www/js/evm/
189
+ * session.js, readContract), so it is dropped into the SDK WITHOUT an adapter. This is a CHAIN READ, not a node
190
+ * substitution: the calls go to the real node at the rpcUrl address.
191
+ */
192
+ export function evmRpcReader({ url }) {
193
+ const provider = evmRpcProvider(url);
194
+ return ({ to, data } = {}) => {
195
+ if (!to || !data) throw new SdkError("bad-input", { field: "eth_call" });
196
+ return provider.request({ method: "eth_call", params: [{ to, data }, "latest"] });
197
+ };
198
+ }
199
+
200
+ // --- THE PRODUCTION-NETWORK MARKER ----------------------------------------------------------------------
201
+ /**
202
+ * A PRODUCTION NETWORK - BY THE ENGINE'S RULE, NOT ITS OWN. The marker is taken from www/js/core/config.js (the
203
+ * testnet field) and sdk/src/swap-flow.mjs (escrow.mode === "live"): on a production network there is a live
204
+ * escrow and NO testnet marker. An unknown network is also considered production: there is nothing to prove it is a
205
+ * test one, and an error in that direction costs a test key signing over real money.
206
+ */
207
+ export function isProductionChain(chainId) {
208
+ const chain = typeof engineConfig.chainByChainId === "function" ? engineConfig.chainByChainId(Number(chainId)) : null;
209
+ if (!chain) return { production: true, reason: "unknown-chain", chain: null };
210
+ const escrow = chain.escrow && typeof chain.escrow === "object" ? chain.escrow : null;
211
+ const production = Boolean(escrow && escrow.mode === "live" && !chain.testnet);
212
+ return { production, reason: production ? "production" : "testnet", chain };
213
+ }
214
+
215
+ // --- THE ADAPTER ----------------------------------------------------------------------------------------
216
+ /**
217
+ * A test EVM wallet: the same interface as the page's wallet (vm, connect, disconnect, address, chainId, subscribe,
218
+ * driver + send/receipt), but the signature is its own and the chain is read directly.
219
+ *
220
+ * IT ACCEPTS ONLY AN ALREADY-LOADED KEY: the loader is testPrivateKeyFromEnv above, and there is no other entry.
221
+ * rpcUrl and chainId come from outside, but as VALUES (not the key), and the adapter keeps no factory address at
222
+ * all: the addresses come from the SDK/engine's network registry, and a second source of truth about addresses must
223
+ * not be introduced.
224
+ */
225
+ export function nodeTestEvmWallet({ privateKey, rpcUrl, chainId = null, id = "node-test" } = {}) {
226
+ const hex = String(privateKey || "").replace(/^0x/, "");
227
+ if (!/^[0-9a-fA-F]{64}$/.test(hex)) throw new SdkError("bad-input", { field: "privateKey" });
228
+ const key = "0x" + hex.toLowerCase();
229
+ const owner = addressOfPrivateKey(key);
230
+ const provider = evmRpcProvider(rpcUrl);
231
+ let connected = false;
232
+
233
+ const remoteChainId = async () => Number(BigInt(await provider.request({ method: "eth_chainId" })));
234
+
235
+ // REFUSAL ON A PRODUCTION NETWORK - ONE POINT THROUGH WHICH EVERY SIGNATURE PASSES. The check stands here, not in
236
+ // connect(), deliberately: connect may happen once and early, while the node's network may change later (or turn
237
+ // out to be the wrong one at the moment of signing). We ask the network OF THE NODE right before signing.
238
+ const assertTestContour = async () => {
239
+ const remote = await remoteChainId();
240
+ // A NETWORK OUTSIDE THE WHITELIST IS A REFUSAL NAMING THE NETWORK, BEFORE ANY SIGNATURE. This is the FIRST line
241
+ // of defense and it does not depend on how the network is marked in the registry (the production marker is the
242
+ // second line below, and today it may not trigger).
243
+ if (!TEST_CHAIN_IDS.includes(Number(remote))) {
244
+ throw new SdkError("wrong-chain", {
245
+ why: "test-contour-network-not-allowed", chainId: remote, allowed: [...TEST_CHAIN_IDS],
246
+ });
247
+ }
248
+ // THE SECOND LINE - THE ENGINE'S PRODUCTION-NETWORK RULE (not rewritten here, taken from the registry). It stays
249
+ // for the case where a network from the list is later marked production: then the signature still stops.
250
+ const view = isProductionChain(remote);
251
+ if (view.production) {
252
+ throw new SdkError("wrong-chain", {
253
+ why: "test-contour-refuses-live-network", chainId: remote, reason: view.reason,
254
+ escrowMode: view.chain && view.chain.escrow ? view.chain.escrow.mode : null,
255
+ testnet: view.chain ? Boolean(view.chain.testnet) : null,
256
+ });
257
+ }
258
+ if (chainId !== null && Number(chainId) !== remote) {
259
+ throw new SdkError("wrong-chain", { got: remote, want: Number(chainId) });
260
+ }
261
+ return remote;
262
+ };
263
+
264
+ return {
265
+ vm: "evm",
266
+ id,
267
+ // The address going out is what is anyway visible in every transaction. The key is not handed out by any method.
268
+ async connect(options = {}) {
269
+ const want = options && options.chainId !== undefined && options.chainId !== null ? Number(options.chainId) : chainId;
270
+ const remote = await remoteChainId();
271
+ if (want !== null && want !== undefined && Number(want) !== remote) {
272
+ throw new SdkError("wrong-chain", { got: remote, want: Number(want) });
273
+ }
274
+ connected = true;
275
+ },
276
+ async disconnect() {
277
+ connected = false;
278
+ },
279
+ address() {
280
+ return owner;
281
+ },
282
+ async chainId() {
283
+ return remoteChainId();
284
+ },
285
+ // NO SUBSCRIPTION: a local run has no "wallet changed network" event, while a poll timer would keep the process
286
+ // alive after the last transaction. We return an unsubscribe function, as the adapter interface requires.
287
+ subscribe() {
288
+ return () => {};
289
+ },
290
+ driver() {
291
+ return provider;
292
+ },
293
+ /**
294
+ * SENDING: sign with its own key and eth_sendRawTransaction. The nonce is taken by pending, the gas price - from
295
+ * the node, the gas limit - from the caller (the lock passes its own; if it did not, we estimate with a margin).
296
+ * None of these numbers is invented: a guessed gas limit costs a signature, and a guessed nonce costs a lost
297
+ * transaction.
298
+ */
299
+ async send({ to, data = "0x", value = 0n, gas = null }) {
300
+ if (!connected) throw new SdkError("wallet-not-connected");
301
+ if (typeof to !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(to)) throw new SdkError("bad-input", { field: "send.to" });
302
+ const remote = await assertTestContour();
303
+ const valueWei = BigInt(value === undefined || value === null ? 0 : value);
304
+ const nonce = Number(BigInt(await provider.request({ method: "eth_getTransactionCount", params: [owner, "pending"] })));
305
+ const gasPrice = BigInt(await provider.request({ method: "eth_gasPrice" }));
306
+ let gasLimit = gas === null || gas === undefined ? null : BigInt(gas);
307
+ if (gasLimit === null) {
308
+ const est = BigInt(await provider.request({
309
+ method: "eth_estimateGas",
310
+ params: [{ from: owner, to, value: "0x" + valueWei.toString(16), data }],
311
+ }));
312
+ gasLimit = (est * 12n) / 10n;
313
+ }
314
+ const signed = signLegacyTx({
315
+ privateKey: key, nonce, gasPrice, gasLimit, to, data,
316
+ value: valueWei, chainId: remote,
317
+ });
318
+ const hash = await provider.request({ method: "eth_sendRawTransaction", params: [signed.raw] });
319
+ if (typeof hash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(hash)) {
320
+ throw new SdkError("server-unavailable", { step: "send-raw" });
321
+ }
322
+ // THE HASH IS CHECKED AGAINST THE LOCALLY COMPUTED ONE. A discrepancy means the node accepted a transaction
323
+ // different from the one we signed - and that must be found out here, not from missing money.
324
+ if (hash.toLowerCase() !== signed.hash.toLowerCase()) {
325
+ throw new SdkError("contract-reverted", { step: "hash-mismatch", node: hash, local: signed.hash });
326
+ }
327
+ return hash;
328
+ },
329
+ /**
330
+ * THE RECEIPT: asked once and returned as is (null - not in a block yet). THE ARGUMENT SHAPE IS ACCEPTED BOTH
331
+ * WAYS: a hash string and an object { hash }. This is not "just in case": the adapter contract declares
332
+ * receipt(txHash: string), while the lock path in the SDK calls the receipt as receipt({ hash }) - the engine's
333
+ * waitReceipt passes an object (www/js/evm/funding.js), and lock.mjs passes it into the adapter without
334
+ * normalisation. A strict adapter answers bad-input to this, the refusal is swallowed by the wait, and the
335
+ * escrow address is silently replaced with the PREDICTED one - that is, the "address from the receipt" check
336
+ * stops working without saying so.
337
+ */
338
+ async receipt(txHash) {
339
+ const hash = typeof txHash === "string" ? txHash : (txHash && typeof txHash.hash === "string" ? txHash.hash : null);
340
+ if (!hash) throw new SdkError("bad-input", { field: "receipt.txHash" });
341
+ const r = await provider.request({ method: "eth_getTransactionReceipt", params: [hash] });
342
+ return r || null;
343
+ },
344
+ };
345
+ }