@farmslot/adapter-web 0.1.0 → 0.3.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.
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+
3
+ // Read, window and assert the wallet request log a wallet host writes: one
4
+ // JSON line per request the app sent to the wallet (method, EIP-712 primary
5
+ // type, domain/active chain, outcome) and, with a wallet extension, one line
6
+ // per confirmation window it opened. Never holds params, messages or
7
+ // signatures. A cursor file marks where the current window starts.
8
+ //
9
+ // `log` is `{ logFile, cursorFile }`. `policy` adds what the product forbids:
10
+ // - `typedDataClasses`: `{ [counter]: { match(entry), param, defaultMax, failure(count, max) } }`,
11
+ // typed-data requests counted under `counter`, at most `node[param]`
12
+ // (default `defaultMax`) allowed;
13
+ // - `forbiddenEntries`: `{ [counter]: { match(entry), failure(count) } }`, log
14
+ // entries that fail the assertion whenever one is present.
15
+
16
+ const { mkdir, readFile, writeFile } = require('node:fs/promises');
17
+ const path = require('node:path');
18
+
19
+ /**
20
+ * @typedef {{ logFile: string, cursorFile: string }} SignatureLog
21
+ * @typedef {{ seq: number, kind?: string, [field: string]: any }} LogEntry
22
+ * @typedef {{ requested: number, signed: number, rejected: number, chainIds: Array<number | null | undefined> }} PrimaryTypeSummary
23
+ * @typedef {object} SignatureSummary
24
+ * @property {number} requests
25
+ * @property {number} typedDataRequests
26
+ * @property {number} confirmationsShown
27
+ * @property {number} unattributed
28
+ * @property {number} outsideAppFrame
29
+ * @property {Record<string, PrimaryTypeSummary>} byPrimaryType
30
+ * @property {Record<string, number>} byMethod
31
+ * @typedef {object} SignaturePolicy
32
+ * @property {Record<string, { match(entry: LogEntry): boolean, param: string, defaultMax: number, failure(count: number, max: number): string }>} [typedDataClasses]
33
+ * @property {Record<string, { match(entry: LogEntry): boolean, failure(count: number): string }>} [forbiddenEntries]
34
+ */
35
+
36
+ /** @param {string} text @returns {LogEntry[]} */
37
+ function parseLog(text) {
38
+ return text
39
+ .split('\n')
40
+ .filter((line) => line.trim())
41
+ .map((line, index) => {
42
+ try {
43
+ return { seq: index, ...JSON.parse(line) };
44
+ } catch {
45
+ return { seq: index, kind: 'unparseable' };
46
+ }
47
+ });
48
+ }
49
+
50
+ /** @param {SignatureLog} log @returns {Promise<LogEntry[]>} */
51
+ async function readLog({ logFile }) {
52
+ try {
53
+ return parseLog(await readFile(logFile, 'utf8'));
54
+ } catch (error) {
55
+ if (error?.code === 'ENOENT') return [];
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ /** @param {SignatureLog} log @returns {Promise<number>} */
61
+ async function readCursor({ cursorFile }) {
62
+ try {
63
+ const cursor = JSON.parse(await readFile(cursorFile, 'utf8'));
64
+ return Number.isInteger(cursor.seq) ? cursor.seq : 0;
65
+ } catch (error) {
66
+ if (error?.code === 'ENOENT') return 0;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ /** @param {SignatureLog} log @param {number} seq */
72
+ async function writeCursor({ cursorFile }, seq) {
73
+ await writeFile(cursorFile, `${JSON.stringify({ seq, at: new Date().toISOString() })}\n`, {
74
+ mode: 0o600,
75
+ });
76
+ }
77
+
78
+ // "Token:Permit" → "Permit".
79
+ /** @param {unknown} primaryType @returns {string | null} */
80
+ function shortPrimaryType(primaryType) {
81
+ if (primaryType == null) return null;
82
+ const raw = String(primaryType);
83
+ return raw.includes(':') ? raw.slice(raw.lastIndexOf(':') + 1) : raw;
84
+ }
85
+
86
+ /** @param {LogEntry} entry */
87
+ function isTypedDataRequest(entry) {
88
+ return entry.kind === 'request' && /^eth_signTypedData/u.test(String(entry.method));
89
+ }
90
+
91
+ /**
92
+ * @param {LogEntry[]} entries
93
+ * @param {SignaturePolicy} [policy]
94
+ * @returns {SignatureSummary & Record<string, any>} The policy's counters are extra top-level keys.
95
+ */
96
+ function summarize(entries, policy = {}) {
97
+ assertPolicyCounters(policy);
98
+ const requests = entries.filter((entry) => entry.kind === 'request');
99
+ const typed = requests.filter(isTypedDataRequest);
100
+ /** @type {Record<string, PrimaryTypeSummary>} */
101
+ const byPrimaryType = {};
102
+ for (const entry of typed) {
103
+ const key = shortPrimaryType(entry.primaryType) ?? '<none>';
104
+ byPrimaryType[key] ??= { requested: 0, signed: 0, rejected: 0, chainIds: [] };
105
+ byPrimaryType[key].requested += 1;
106
+ if (entry.outcome === 'signed') byPrimaryType[key].signed += 1;
107
+ if (entry.outcome === 'rejected' || entry.outcome === 'error') byPrimaryType[key].rejected += 1;
108
+ if (!byPrimaryType[key].chainIds.includes(entry.domainChainId))
109
+ byPrimaryType[key].chainIds.push(entry.domainChainId);
110
+ }
111
+ /** @type {Record<string, number>} */
112
+ const byMethod = {};
113
+ for (const entry of requests) byMethod[entry.method] = (byMethod[entry.method] ?? 0) + 1;
114
+ const counted = (rules, list) =>
115
+ Object.fromEntries(
116
+ Object.entries(rules ?? {}).map(([counter, rule]) => [
117
+ counter,
118
+ list.filter(rule.match).length,
119
+ ]),
120
+ );
121
+ return {
122
+ requests: requests.length,
123
+ typedDataRequests: typed.length,
124
+ ...counted(policy.typedDataClasses, typed),
125
+ confirmationsShown: entries.filter((entry) => entry.kind === 'confirmation-shown').length,
126
+ // Forbidden entries and evidence gaps: each one fails the assertion.
127
+ ...counted(policy.forbiddenEntries, entries),
128
+ unattributed: entries.filter((entry) => entry.kind === 'unattributed').length,
129
+ // Calls from an iframe, a foreign page or a blank document: refused, kept as evidence.
130
+ outsideAppFrame: entries.filter((entry) => entry.kind === 'outside-app-frame').length,
131
+ byPrimaryType,
132
+ byMethod,
133
+ };
134
+ }
135
+
136
+ // The summary's own keys; a policy counter may not reuse one.
137
+ const SUMMARY_KEYS = Object.freeze([
138
+ 'requests',
139
+ 'typedDataRequests',
140
+ 'confirmationsShown',
141
+ 'unattributed',
142
+ 'outsideAppFrame',
143
+ 'byPrimaryType',
144
+ 'byMethod',
145
+ ]);
146
+
147
+ /** @param {SignaturePolicy} policy */
148
+ function assertPolicyCounters(policy) {
149
+ const names = [
150
+ ...Object.keys(policy.typedDataClasses ?? {}),
151
+ ...Object.keys(policy.forbiddenEntries ?? {}),
152
+ ];
153
+ const clash = names.find(
154
+ (name, index) => SUMMARY_KEYS.includes(name) || names.indexOf(name) !== index,
155
+ );
156
+ if (clash !== undefined) {
157
+ throw new Error(
158
+ `signature log policy counter "${clash}" is a summary key or used twice; name it something else.`,
159
+ );
160
+ }
161
+ }
162
+
163
+ function exact(label, actual, expected, failures) {
164
+ if (actual !== expected) failures.push(`${label}: expected ${expected}, got ${actual}`);
165
+ }
166
+
167
+ // Every expectation in `node` is optional.
168
+ /**
169
+ * @param {LogEntry[]} entries
170
+ * @param {Record<string, any>} [node]
171
+ * @param {SignaturePolicy} [policy]
172
+ * @returns {{ ok: boolean, failures: string[], summary: SignatureSummary & Record<string, any> }}
173
+ */
174
+ function evaluateSignatureLog(entries, node = {}, policy = {}) {
175
+ const summary = summarize(entries, policy);
176
+ const typed = entries.filter(isTypedDataRequest);
177
+ const failures = [];
178
+ for (const [counter, rule] of Object.entries(policy.forbiddenEntries ?? {})) {
179
+ if (summary[counter] > 0) failures.push(rule.failure(summary[counter]));
180
+ }
181
+ if (summary.unattributed > 0)
182
+ failures.push(
183
+ `${summary.unattributed} wallet log entr(y/ies) could not be attributed to the app's top frame (unattributed)`,
184
+ );
185
+ for (const [counter, rule] of Object.entries(policy.typedDataClasses ?? {})) {
186
+ const max = node[rule.param] == null ? rule.defaultMax : Number(node[rule.param]);
187
+ if (summary[counter] > max) failures.push(rule.failure(summary[counter], max));
188
+ }
189
+ if (Array.isArray(node.allowed_primary_types)) {
190
+ const allowed = new Set(node.allowed_primary_types.map(shortPrimaryType));
191
+ for (const entry of typed) {
192
+ const type = shortPrimaryType(entry.primaryType);
193
+ if (!allowed.has(type))
194
+ failures.push(`primary type ${type} (seq ${entry.seq}) is not allowed`);
195
+ }
196
+ }
197
+ if (node.counts && typeof node.counts === 'object') {
198
+ for (const [type, expected] of Object.entries(node.counts)) {
199
+ exact(
200
+ `signed ${shortPrimaryType(type)}`,
201
+ summary.byPrimaryType[shortPrimaryType(type)]?.signed ?? 0,
202
+ Number(expected),
203
+ failures,
204
+ );
205
+ }
206
+ }
207
+ if (node.request_counts && typeof node.request_counts === 'object') {
208
+ for (const [type, expected] of Object.entries(node.request_counts)) {
209
+ exact(
210
+ `requested ${shortPrimaryType(type)}`,
211
+ summary.byPrimaryType[shortPrimaryType(type)]?.requested ?? 0,
212
+ Number(expected),
213
+ failures,
214
+ );
215
+ }
216
+ }
217
+ if (node.methods && typeof node.methods === 'object') {
218
+ for (const [method, expected] of Object.entries(node.methods)) {
219
+ exact(`method ${method}`, summary.byMethod[method] ?? 0, Number(expected), failures);
220
+ }
221
+ }
222
+ if (
223
+ node.max_typed_data_requests != null &&
224
+ summary.typedDataRequests > Number(node.max_typed_data_requests)
225
+ ) {
226
+ failures.push(
227
+ `typed-data requests ${summary.typedDataRequests} > ${node.max_typed_data_requests}`,
228
+ );
229
+ }
230
+ if (node.expect_confirmations != null) {
231
+ exact(
232
+ 'confirmations shown',
233
+ summary.confirmationsShown,
234
+ Number(node.expect_confirmations),
235
+ failures,
236
+ );
237
+ }
238
+ if (node.require_active_chain === true) {
239
+ for (const entry of typed.filter((item) => item.outcome === 'signed')) {
240
+ if (Number(entry.domainChainId) !== Number(entry.activeChainId)) {
241
+ failures.push(
242
+ `seq ${entry.seq} ${shortPrimaryType(entry.primaryType)} signed with domain chainId ${entry.domainChainId} while the wallet was on ${entry.activeChainId}`,
243
+ );
244
+ }
245
+ }
246
+ }
247
+ if (node.max_rejected != null) {
248
+ const rejected = typed.filter(
249
+ (entry) => entry.outcome === 'rejected' || entry.outcome === 'error',
250
+ ).length;
251
+ if (rejected > Number(node.max_rejected))
252
+ failures.push(`rejected typed-data requests ${rejected} > ${node.max_rejected}`);
253
+ }
254
+ return { ok: failures.length === 0, failures, summary };
255
+ }
256
+
257
+ /** @param {SignatureLog} log */
258
+ async function windowSinceCursor(log) {
259
+ const [entries, cursor] = await Promise.all([readLog(log), readCursor(log)]);
260
+ return { cursor, entries: entries.filter((entry) => entry.seq >= cursor), total: entries.length };
261
+ }
262
+
263
+ // Move the window start to the end of the log, so the next read covers only
264
+ // what follows.
265
+ /** @param {SignatureLog} log */
266
+ async function resetWindow(log) {
267
+ const entries = await readLog(log);
268
+ await writeCursor(log, entries.length);
269
+ return { cursor: entries.length, previousEntries: entries.length };
270
+ }
271
+
272
+ // Evaluate the current window; entries arrive asynchronously from the wallet
273
+ // host, so a failing evaluation is retried until `node.timeout_ms` passes.
274
+ /** @param {SignatureLog} log @param {Record<string, any>} [node] @param {SignaturePolicy} [policy] */
275
+ async function awaitSignatureLog(log, node = {}, policy = {}) {
276
+ const deadline = Date.now() + Math.max(0, Number(node.timeout_ms ?? 0));
277
+ let window = await windowSinceCursor(log);
278
+ let result = evaluateSignatureLog(window.entries, node, policy);
279
+ while (!result.ok && Date.now() < deadline) {
280
+ await new Promise((resolve) => setTimeout(resolve, 500));
281
+ window = await windowSinceCursor(log);
282
+ result = evaluateSignatureLog(window.entries, node, policy);
283
+ }
284
+ return { window, result };
285
+ }
286
+
287
+ // Retain a window as run evidence: <artifactsDir>/<folder>/<nodeId>-signature-log.json.
288
+ /**
289
+ * @param {{ artifactsDir?: string, nodeId: string, folder: string }} target
290
+ * @param {unknown} payload
291
+ * @returns {Promise<Array<{ path: string, type: 'report', nodeId: string }>>}
292
+ */
293
+ async function writeLogArtifact({ artifactsDir, nodeId, folder }, payload) {
294
+ if (!artifactsDir) return [];
295
+ const relative = `${folder}/${String(nodeId).replaceAll('/', '__')}-signature-log.json`;
296
+ await mkdir(path.join(artifactsDir, folder), { recursive: true });
297
+ await writeFile(path.join(artifactsDir, relative), `${JSON.stringify(payload, null, 2)}\n`);
298
+ return [{ path: relative, type: 'report', nodeId: String(nodeId) }];
299
+ }
300
+
301
+ module.exports = {
302
+ awaitSignatureLog,
303
+ evaluateSignatureLog,
304
+ isTypedDataRequest,
305
+ parseLog,
306
+ readCursor,
307
+ readLog,
308
+ resetWindow,
309
+ shortPrimaryType,
310
+ summarize,
311
+ windowSinceCursor,
312
+ writeCursor,
313
+ writeLogArtifact,
314
+ };
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ // EIP-1193 test wallet that enforces MetaMask's typed-data rule: reject
4
+ // eth_signTypedData_v4 whose domain chainId differs from the active chain.
5
+ // Signs with a viem-style account ({ address, signTypedData, signMessage })
6
+ // the caller holds; never exposes key material.
7
+
8
+ const DEFAULT_KNOWN_CHAINS = Object.freeze([
9
+ 1, 10, 137, 8453, 42161, 421614, 59144, 59141, 11155111,
10
+ ]);
11
+
12
+ const READ_RPC = {
13
+ 1: 'https://ethereum-rpc.publicnode.com',
14
+ 42161: 'https://arbitrum-one-rpc.publicnode.com',
15
+ 421614: 'https://arbitrum-sepolia-rpc.publicnode.com',
16
+ 59144: 'https://linea-rpc.publicnode.com',
17
+ };
18
+
19
+ // Only these reads go to the public RPC. Anything else the wallet does not
20
+ // answer itself (eth_sendRawTransaction, account or filter methods, unknown
21
+ // wallet_* calls) is refused before any network request.
22
+ const READ_ONLY_RPC_METHODS = Object.freeze([
23
+ 'eth_blockNumber',
24
+ 'eth_call',
25
+ 'eth_estimateGas',
26
+ 'eth_feeHistory',
27
+ 'eth_gasPrice',
28
+ 'eth_getBalance',
29
+ 'eth_getBlockByHash',
30
+ 'eth_getBlockByNumber',
31
+ 'eth_getCode',
32
+ 'eth_getLogs',
33
+ 'eth_getStorageAt',
34
+ 'eth_getTransactionByHash',
35
+ 'eth_getTransactionCount',
36
+ 'eth_getTransactionReceipt',
37
+ 'eth_maxPriorityFeePerGas',
38
+ 'web3_clientVersion',
39
+ ]);
40
+ const READ_ONLY = new Set(READ_ONLY_RPC_METHODS);
41
+
42
+ function rpcError(message, code) {
43
+ return Object.assign(new Error(message), { code });
44
+ }
45
+
46
+ function toBigIntDeep(types, primaryType, message) {
47
+ const out = {};
48
+ for (const field of types[primaryType] ?? []) {
49
+ const value = message[field.name];
50
+ if (value === undefined) continue;
51
+ if (/^u?int\d*$/u.test(field.type)) out[field.name] = BigInt(value);
52
+ else if (types[field.type]) out[field.name] = toBigIntDeep(types, field.type, value);
53
+ else out[field.name] = value;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * @param {object} options
60
+ * @param {{ address: string, signTypedData(args: any): Promise<string>, signMessage(args: any): Promise<string> }} options.account
61
+ * @param {number} [options.chainId]
62
+ * @param {readonly number[]} [options.knownChains]
63
+ * @param {boolean} [options.refuseSwitch]
64
+ * @param {typeof fetch} [options.fetchImpl]
65
+ */
66
+ function createStrictWallet({
67
+ account,
68
+ chainId = 42161,
69
+ knownChains = DEFAULT_KNOWN_CHAINS,
70
+ refuseSwitch = false,
71
+ fetchImpl = globalThis.fetch,
72
+ }) {
73
+ let activeChain = chainId;
74
+ const known = new Set(knownChains);
75
+ let rpcId = 1;
76
+
77
+ /** @param {{ method: string, params?: any[] }} args @returns {Promise<any>} */
78
+ async function request({ method, params = [] }) {
79
+ switch (method) {
80
+ case 'eth_chainId':
81
+ return `0x${activeChain.toString(16)}`;
82
+ case 'net_version':
83
+ return String(activeChain);
84
+ case 'eth_accounts':
85
+ case 'eth_requestAccounts':
86
+ return [account.address];
87
+ case 'wallet_requestPermissions':
88
+ case 'wallet_getPermissions':
89
+ return [
90
+ {
91
+ parentCapability: 'eth_accounts',
92
+ caveats: [{ type: 'restrictReturnedAccounts', value: [account.address.toLowerCase()] }],
93
+ },
94
+ ];
95
+ case 'wallet_revokePermissions':
96
+ return null;
97
+ case 'metamask_getProviderState':
98
+ return {
99
+ accounts: [account.address.toLowerCase()],
100
+ chainId: `0x${activeChain.toString(16)}`,
101
+ isUnlocked: true,
102
+ networkVersion: String(activeChain),
103
+ };
104
+ case 'wallet_switchEthereumChain': {
105
+ const id = Number.parseInt(params[0].chainId, 16);
106
+ if (refuseSwitch) throw rpcError('User rejected the request.', 4001);
107
+ if (!known.has(id)) throw rpcError(`Unrecognized chain ID ${params[0].chainId}`, 4902);
108
+ activeChain = id;
109
+ return null;
110
+ }
111
+ case 'wallet_addEthereumChain':
112
+ known.add(Number.parseInt(params[0].chainId, 16));
113
+ return null;
114
+ case 'eth_signTypedData_v4': {
115
+ const [from, raw] = params;
116
+ const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
117
+ const domainChain = Number(BigInt(data.domain.chainId));
118
+ if (String(from).toLowerCase() !== account.address.toLowerCase())
119
+ throw rpcError('Unknown account', 4100);
120
+ if (domainChain !== activeChain) {
121
+ throw rpcError(
122
+ `Provided chainId "${domainChain}" must match the active chainId "${activeChain}"`,
123
+ -32602,
124
+ );
125
+ }
126
+ const types = { ...data.types };
127
+ delete types.EIP712Domain;
128
+ return account.signTypedData({
129
+ domain: { ...data.domain, chainId: domainChain },
130
+ types,
131
+ primaryType: data.primaryType,
132
+ message: toBigIntDeep(types, data.primaryType, data.message),
133
+ });
134
+ }
135
+ case 'personal_sign': {
136
+ const [message, from] = params;
137
+ if (String(from).toLowerCase() !== account.address.toLowerCase())
138
+ throw rpcError('Unknown account', 4100);
139
+ return account.signMessage({ message: { raw: message } });
140
+ }
141
+ case 'eth_sendTransaction':
142
+ case 'eth_sign':
143
+ throw rpcError(`${method} is not supported by the strict test wallet`, 4200);
144
+ default: {
145
+ if (!READ_ONLY.has(method))
146
+ throw rpcError(`${method} is not supported by the strict test wallet`, 4200);
147
+ const url = READ_RPC[activeChain];
148
+ if (!url) throw rpcError(`No read RPC for chain ${activeChain}`, 4200);
149
+ const response = await fetchImpl(url, {
150
+ method: 'POST',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method, params }),
153
+ });
154
+ const body = await response.json();
155
+ if (body.error) throw rpcError(body.error.message, body.error.code);
156
+ return body.result;
157
+ }
158
+ }
159
+ }
160
+
161
+ return {
162
+ request,
163
+ get chainId() {
164
+ return activeChain;
165
+ },
166
+ };
167
+ }
168
+
169
+ module.exports = { DEFAULT_KNOWN_CHAINS, READ_ONLY_RPC_METHODS, createStrictWallet };