request-wallet-sign 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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/index.js +1142 -0
- package/package.json +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elliott Alexander
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# request-wallet-sign
|
|
2
|
+
|
|
3
|
+
A standalone `npx` utility that lets AI agents surface wallet signing requests to a user via a local browser page. The agent passes a fully-constructed transaction as a CLI argument; a local HTTP server serves a browser page pre-populated with the details. The user connects their browser wallet (MetaMask, Rabby, Coinbase Wallet, โฆ), reviews the decoded transaction, and approves. The wallet signs/broadcasts, and the resulting hash or signature is returned to the agent on stdout.
|
|
4
|
+
|
|
5
|
+
The agent never holds a private key.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx request-wallet-sign '<request JSON>'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Sign from another device (phone, tablet, another computer):
|
|
15
|
+
# Click the "๐ฑ Sign on another device" button in the page, or pre-start the
|
|
16
|
+
# tunnel on page load with --tunnel:
|
|
17
|
+
npx request-wallet-sign --tunnel '<request JSON>'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
On success, prints `{"hash":"0xโฆ","chainId":N}` (or `{"signature":"0xโฆ","chainId":N}`) to stdout and exits 0. On rejection, missing wallet, or a 5-minute timeout, prints to stderr and exits 1.
|
|
21
|
+
|
|
22
|
+
## Signing on another device
|
|
23
|
+
|
|
24
|
+
The default invocation is **local-only** โ instant page, no tunnel, no background processes, no state files.
|
|
25
|
+
|
|
26
|
+
To sign from a phone, tablet, or another computer, the page has a **"๐ฑ Sign on another device"** button. Clicking it starts (or reuses) a [Cloudflare quick tunnel](https://www.npmjs.com/package/cloudflared) via `npx` (auto-downloaded on first run, **no Cloudflare account needed**) and shows a public `https://*.trycloudflare.com` URL. A **"Check reachability"** button verifies the tunnel server-side when you choose. Open the HTTPS URL inside the **wallet app's built-in browser** on the other device โ cross-device needs HTTPS because mobile wallets only inject `window.ethereum` over a secure origin.
|
|
27
|
+
|
|
28
|
+
Pass `--tunnel` to pre-start the tunnel automatically on page load (handy for agents that already know they need cross-device).
|
|
29
|
+
|
|
30
|
+
**Reuse:** the tunnel is recorded in `~/.request-wallet-sign/state.json` and **reused across invocations**, so signing many transactions in a row does not create many tunnels (which would get rate-limited by Cloudflare). It is a single shared background process, reaped after 10 minutes idle or immediately with:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx request-wallet-sign --stop-tunnel
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Security note:** an active tunnel makes the signing page reachable by anyone holding the random (unguessable) URL โ they can see transaction details but cannot sign without the user's wallet. Nothing leaves your machine until you start a tunnel.
|
|
37
|
+
|
|
38
|
+
## Request format
|
|
39
|
+
|
|
40
|
+
Operation type is inferred from the JSON shape (no `type` field). Priority: `typedData` โ `message` โ transaction.
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
// eth_sendTransaction
|
|
44
|
+
{ "chainId": 1,
|
|
45
|
+
"to": "0xโฆ", "data": "0xโฆ", "value": "0x0",
|
|
46
|
+
"gas": "0xโฆ", "maxFeePerGas": "0xโฆ", "maxPriorityFeePerGas": "0xโฆ" }
|
|
47
|
+
|
|
48
|
+
// eth_signTypedData_v4
|
|
49
|
+
{ "chainId": 1, "typedData": { "domain": {}, "types": {}, "primaryType": "โฆ", "message": {} } }
|
|
50
|
+
|
|
51
|
+
// personal_sign
|
|
52
|
+
{ "chainId": 1, "message": "I authorize this" }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `to` omitted โ contract deployment. `value` defaults to `"0x0"`.
|
|
56
|
+
- `gas` / `maxFeePerGas` / `maxPriorityFeePerGas` optional โ estimated browser-side (EIP-1559 / type-2 only). Fee estimation degrades gracefully if the wallet's RPC lacks `eth_maxPriorityFeePerGas`.
|
|
57
|
+
- The page shows the user a plain-English summary **derived from the transaction data itself** (ERC-7730 clear signing where available, otherwise decoded function + token info) โ the requester cannot supply or override this text. Always confirm in your wallet.
|
|
58
|
+
|
|
59
|
+
## Behavior
|
|
60
|
+
|
|
61
|
+
- Browser opens automatically on the machine running the command.
|
|
62
|
+
- The page shows a transaction summary (chain name, recipient, value in ETH), decoded calldata (best-effort via whatsabi), and copy buttons (copy recipient, copy network/tunnel URL, copy full tx data).
|
|
63
|
+
- On success the tab attempts to auto-close.
|
|
64
|
+
- 5-minute timeout, measured from launch.
|
|
65
|
+
|
|
66
|
+
## Run the tests
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm test
|
|
70
|
+
```
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// โโ Request parsing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
4
|
+
|
|
5
|
+
// Parse CLI options (flags) from argv, separate from the request JSON.
|
|
6
|
+
export function parseOptions(argv) {
|
|
7
|
+
const args = argv.slice(2);
|
|
8
|
+
return {
|
|
9
|
+
tunnel: args.includes('--tunnel'),
|
|
10
|
+
stopTunnel: args.includes('--stop-tunnel'),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseRequest(argv) {
|
|
15
|
+
// The request JSON is the first argument that isn't a --flag.
|
|
16
|
+
const raw = argv.slice(2).find(a => !a.startsWith('--'));
|
|
17
|
+
if (!raw) {
|
|
18
|
+
throw new Error("Usage: request-wallet-sign [--tunnel] '<request JSON>'");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(raw);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
throw new Error(`Invalid JSON: ${e.message}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof parsed.chainId !== 'number' || !Number.isInteger(parsed.chainId)) {
|
|
29
|
+
throw new Error('Request must include "chainId" as an integer (e.g. 1 for Ethereum mainnet)');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Infer operation type โ typedData > message > sendTransaction
|
|
33
|
+
let _type;
|
|
34
|
+
if (parsed.typedData !== undefined) {
|
|
35
|
+
_type = 'signTypedData';
|
|
36
|
+
} else if (parsed.message !== undefined) {
|
|
37
|
+
_type = 'personalSign';
|
|
38
|
+
} else {
|
|
39
|
+
_type = 'sendTransaction';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const req = { ...parsed, _type };
|
|
43
|
+
|
|
44
|
+
// Agent-supplied free text is untrusted and must never be shown as if it
|
|
45
|
+
// described the transaction; the page derives its summary from the data.
|
|
46
|
+
delete req.label;
|
|
47
|
+
delete req.description;
|
|
48
|
+
|
|
49
|
+
// Default value for sendTransaction
|
|
50
|
+
if (_type === 'sendTransaction' && req.value === undefined) {
|
|
51
|
+
req.value = '0x0';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return req;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// โโ Port finding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
58
|
+
|
|
59
|
+
import { createServer as createNetServer } from 'node:net';
|
|
60
|
+
import { networkInterfaces, homedir } from 'node:os';
|
|
61
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, openSync, closeSync } from 'node:fs';
|
|
62
|
+
import { join } from 'node:path';
|
|
63
|
+
|
|
64
|
+
function stateDir() {
|
|
65
|
+
return process.env.REQUEST_WALLET_SIGN_HOME || join(homedir(), '.request-wallet-sign');
|
|
66
|
+
}
|
|
67
|
+
function stateFilePath() { return join(stateDir(), 'state.json'); }
|
|
68
|
+
|
|
69
|
+
export function readState() {
|
|
70
|
+
try { return JSON.parse(readFileSync(stateFilePath(), 'utf8')); }
|
|
71
|
+
catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
export function writeState(state) {
|
|
74
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
75
|
+
writeFileSync(stateFilePath(), JSON.stringify(state));
|
|
76
|
+
}
|
|
77
|
+
export function clearState() {
|
|
78
|
+
try { rmSync(stateFilePath()); } catch {}
|
|
79
|
+
}
|
|
80
|
+
export function isPidAlive(pid) {
|
|
81
|
+
if (!pid) return false;
|
|
82
|
+
try { process.kill(pid, 0); return true; }
|
|
83
|
+
catch (e) { return e.code === 'EPERM'; } // alive but owned by another user
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const TUNNEL_TTL_MS = 10 * 60 * 1000;
|
|
87
|
+
|
|
88
|
+
// Pure: caller passes `alive` (computed via isPidAlive) so this stays testable.
|
|
89
|
+
export function decideTunnelAction(state, port, now, alive, ttl = TUNNEL_TTL_MS) {
|
|
90
|
+
if (!state || !state.url || !alive) return { action: 'start' };
|
|
91
|
+
if (now - state.lastUsedAt >= ttl) return { action: 'replace', pid: state.pid };
|
|
92
|
+
if (state.port !== port) return { action: 'start' };
|
|
93
|
+
return { action: 'reuse', url: state.url };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function findAvailablePort() {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const srv = createNetServer();
|
|
99
|
+
srv.listen(0, '0.0.0.0', () => {
|
|
100
|
+
const port = srv.address().port;
|
|
101
|
+
srv.close(() => resolve(port));
|
|
102
|
+
});
|
|
103
|
+
srv.on('error', reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isPortFree(port) {
|
|
108
|
+
return new Promise(resolve => {
|
|
109
|
+
const srv = createNetServer();
|
|
110
|
+
srv.once('error', () => resolve(false));
|
|
111
|
+
srv.listen(port, '0.0.0.0', () => srv.close(() => resolve(true)));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function choosePort(preferred) {
|
|
116
|
+
if (await isPortFree(preferred)) return preferred;
|
|
117
|
+
return findAvailablePort();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getLocalNetworkIP() {
|
|
121
|
+
const nets = networkInterfaces();
|
|
122
|
+
for (const ifaces of Object.values(nets)) {
|
|
123
|
+
for (const iface of ifaces) {
|
|
124
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
125
|
+
return iface.address;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// โโ HTTP server โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
133
|
+
|
|
134
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
135
|
+
|
|
136
|
+
function isLoopbackReq(req) {
|
|
137
|
+
const a = req.socket.remoteAddress || '';
|
|
138
|
+
return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function startServer(port, html, tunnel) {
|
|
142
|
+
let resolveResult, rejectResult;
|
|
143
|
+
const result = new Promise((res, rej) => {
|
|
144
|
+
resolveResult = res;
|
|
145
|
+
rejectResult = rej;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const server = createHttpServer(async (req, res) => {
|
|
149
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
150
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
151
|
+
res.end(html);
|
|
152
|
+
} else if (req.method === 'POST' && req.url === '/result') {
|
|
153
|
+
let body = '';
|
|
154
|
+
const maxBodySize = 64 * 1024; // 64KB limit
|
|
155
|
+
req.on('data', chunk => {
|
|
156
|
+
body += chunk;
|
|
157
|
+
if (body.length > maxBodySize) {
|
|
158
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
159
|
+
res.end('{"error":"body too large"}');
|
|
160
|
+
req.destroy();
|
|
161
|
+
rejectResult(new Error('result body too large'));
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
req.on('end', () => {
|
|
165
|
+
if (body.length <= maxBodySize) {
|
|
166
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
167
|
+
res.end('{"ok":true}');
|
|
168
|
+
try {
|
|
169
|
+
resolveResult(JSON.parse(body));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
rejectResult(new Error(`Bad result payload: ${e.message}`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
} else if (req.method === 'POST' && req.url === '/tunnel/start' && tunnel) {
|
|
176
|
+
if (!isLoopbackReq(req)) { res.writeHead(403); res.end(); return; }
|
|
177
|
+
const out = await tunnel.start();
|
|
178
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
179
|
+
res.end(JSON.stringify(out));
|
|
180
|
+
} else if (req.method === 'POST' && req.url === '/tunnel/check' && tunnel) {
|
|
181
|
+
if (!isLoopbackReq(req)) { res.writeHead(403); res.end(); return; }
|
|
182
|
+
const out = await tunnel.check();
|
|
183
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
184
|
+
res.end(JSON.stringify(out));
|
|
185
|
+
} else {
|
|
186
|
+
res.writeHead(404);
|
|
187
|
+
res.end();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
server.listen(port, '0.0.0.0');
|
|
192
|
+
server.on('error', (err) => {
|
|
193
|
+
rejectResult(err);
|
|
194
|
+
});
|
|
195
|
+
return { result, close: () => server.close() };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// โโ Browser open โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
199
|
+
|
|
200
|
+
import { spawn } from 'node:child_process';
|
|
201
|
+
|
|
202
|
+
export function openBrowser(url) {
|
|
203
|
+
const cmd =
|
|
204
|
+
process.platform === 'darwin' ? 'open' :
|
|
205
|
+
process.platform === 'win32' ? 'start' :
|
|
206
|
+
'xdg-open';
|
|
207
|
+
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// โโ Cloudflare tunnel primitives โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
211
|
+
export function extractTunnelUrl(text) {
|
|
212
|
+
const m = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
213
|
+
return m ? m[0] : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cloudflaredLogPath() { return join(stateDir(), 'cloudflared.log'); }
|
|
217
|
+
|
|
218
|
+
// Spawn a DETACHED cloudflared quick tunnel that survives CLI exit (for reuse).
|
|
219
|
+
// cloudflared's stdio goes to a LOG FILE โ not parent pipes โ so the process is
|
|
220
|
+
// not killed by SIGPIPE when the CLI exits after signing. We scrape the URL by
|
|
221
|
+
// polling that log file. Resolves { url, pid } or null on failure/timeout.
|
|
222
|
+
function startCloudflared(port) {
|
|
223
|
+
return new Promise(resolve => {
|
|
224
|
+
let settled = false;
|
|
225
|
+
const finish = (proc, url) => {
|
|
226
|
+
if (settled) return;
|
|
227
|
+
settled = true;
|
|
228
|
+
if (url) resolve({ url, pid: proc.pid });
|
|
229
|
+
else { try { proc.kill(); } catch {} resolve(null); }
|
|
230
|
+
};
|
|
231
|
+
try {
|
|
232
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
233
|
+
const logPath = cloudflaredLogPath();
|
|
234
|
+
const fd = openSync(logPath, 'w');
|
|
235
|
+
const proc = spawn('npx', ['-y', 'cloudflared', 'tunnel', '--url', `http://127.0.0.1:${port}`],
|
|
236
|
+
{ detached: true, stdio: ['ignore', fd, fd] });
|
|
237
|
+
closeSync(fd); // child holds its own dup; parent doesn't need it
|
|
238
|
+
proc.on('error', () => finish(proc, null));
|
|
239
|
+
proc.unref();
|
|
240
|
+
const deadline = Date.now() + 25000;
|
|
241
|
+
const tick = () => {
|
|
242
|
+
if (settled) return;
|
|
243
|
+
let txt = '';
|
|
244
|
+
try { txt = readFileSync(logPath, 'utf8'); } catch {}
|
|
245
|
+
const u = extractTunnelUrl(txt);
|
|
246
|
+
if (u) return finish(proc, u);
|
|
247
|
+
if (Date.now() > deadline) return finish(proc, null);
|
|
248
|
+
setTimeout(tick, 400);
|
|
249
|
+
};
|
|
250
|
+
setTimeout(tick, 400);
|
|
251
|
+
} catch {
|
|
252
|
+
resolve(null);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function probeUrl(url) {
|
|
258
|
+
try {
|
|
259
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
260
|
+
return r.status === 200;
|
|
261
|
+
} catch { return false; }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createTunnelController(port, deps = {}) {
|
|
265
|
+
const read = deps.readState || readState;
|
|
266
|
+
const write = deps.writeState || writeState;
|
|
267
|
+
const alive = deps.isPidAlive || isPidAlive;
|
|
268
|
+
const startProc = deps.startCloudflared || startCloudflared;
|
|
269
|
+
const probe = deps.probeUrl || probeUrl;
|
|
270
|
+
const now = deps.now || (() => Date.now());
|
|
271
|
+
const log = deps.log || (() => {});
|
|
272
|
+
const clear = deps.clearState || clearState;
|
|
273
|
+
const kill = deps.kill || killTunnelTree;
|
|
274
|
+
return {
|
|
275
|
+
async start() {
|
|
276
|
+
const t = now();
|
|
277
|
+
const state = read();
|
|
278
|
+
const decision = decideTunnelAction(state, port, t, alive(state && state.pid));
|
|
279
|
+
if (decision.action === 'reuse') {
|
|
280
|
+
write({ ...state, lastUsedAt: t });
|
|
281
|
+
log(`reusing tunnel ${decision.url}`);
|
|
282
|
+
return { url: decision.url };
|
|
283
|
+
}
|
|
284
|
+
if (decision.action === 'replace' && decision.pid) {
|
|
285
|
+
kill(decision.pid);
|
|
286
|
+
}
|
|
287
|
+
const res = await startProc(port);
|
|
288
|
+
if (!res) { clear(); return { error: 'could not start tunnel' }; }
|
|
289
|
+
write({ port, url: res.url, pid: res.pid, startedAt: t, lastUsedAt: t });
|
|
290
|
+
log(`started tunnel ${res.url}`);
|
|
291
|
+
return { url: res.url };
|
|
292
|
+
},
|
|
293
|
+
async check() {
|
|
294
|
+
const state = read();
|
|
295
|
+
if (!state || !state.url) return { reachable: false };
|
|
296
|
+
return { reachable: await probe(state.url) };
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Kill the cloudflared process GROUP. It is spawned detached (its own group
|
|
302
|
+
// leader, pgid === pid), so a negative pid reaches the npx wrapper AND the real
|
|
303
|
+
// cloudflared child; killing just the pid would orphan the child.
|
|
304
|
+
function killTunnelTree(pid) {
|
|
305
|
+
try { process.kill(-pid); } catch {}
|
|
306
|
+
try { process.kill(pid); } catch {}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function stopTunnel(deps = {}) {
|
|
310
|
+
const read = deps.readState || readState;
|
|
311
|
+
const clear = deps.clearState || clearState;
|
|
312
|
+
const kill = deps.kill || killTunnelTree;
|
|
313
|
+
const state = read();
|
|
314
|
+
if (state && state.pid) { try { kill(state.pid); } catch {} }
|
|
315
|
+
clear();
|
|
316
|
+
return state ? state.url : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// โโ Clear-signing decode helpers (pure JS, injected into the browser page) โโโโโ
|
|
320
|
+
|
|
321
|
+
export const DECODE_HELPERS_JS = `
|
|
322
|
+
function awsTrunc(a){ return a ? a.slice(0,6) + '\\u2026' + a.slice(-4) : '\\u2014'; }
|
|
323
|
+
function awsFormatAmount(rawDecimal, decimals){
|
|
324
|
+
const d = BigInt(decimals||0); const neg = String(rawDecimal).startsWith('-');
|
|
325
|
+
let v = BigInt(neg ? String(rawDecimal).slice(1) : rawDecimal);
|
|
326
|
+
const base = 10n ** d; const whole = v / base; let frac = (v % base).toString().padStart(Number(d),'0').replace(/0+$/,'');
|
|
327
|
+
return (neg?'-':'') + whole.toString() + (frac ? '.' + frac : '');
|
|
328
|
+
}
|
|
329
|
+
function awsIsUnlimited(rawDecimal){ try { return BigInt(rawDecimal) >= (2n ** 255n); } catch { return false; } }
|
|
330
|
+
function awsParseSignature(sig){
|
|
331
|
+
const m = String(sig).match(/^([^(]+)\\((.*)\\)$/); if(!m) return { name: String(sig), types: [] };
|
|
332
|
+
const types = m[2].trim() ? m[2].split(',').map(s=>s.trim()) : [];
|
|
333
|
+
return { name: m[1], types };
|
|
334
|
+
}
|
|
335
|
+
function awsPlaceholderTitle(opType){
|
|
336
|
+
return opType === 'personalSign' ? 'Review message'
|
|
337
|
+
: opType === 'signTypedData' ? 'Review typed-data signature'
|
|
338
|
+
: 'Review transaction';
|
|
339
|
+
}
|
|
340
|
+
function awsDescriptorIndexUrl(kind){
|
|
341
|
+
return 'https://raw.githubusercontent.com/ethereum/clear-signing-erc7730-registry/master/registry/index.' + kind + '.json';
|
|
342
|
+
}
|
|
343
|
+
function awsFormatDescriptorField(format, value, params){
|
|
344
|
+
params = params || {};
|
|
345
|
+
if (format === 'tokenAmount' || format === 'amount'){
|
|
346
|
+
const dec = params.decimals != null ? params.decimals : 18;
|
|
347
|
+
const s = awsFormatAmount(String(value), dec);
|
|
348
|
+
return params.ticker ? s + ' ' + params.ticker : s;
|
|
349
|
+
}
|
|
350
|
+
if (format === 'addressName') return params.name ? params.name + ' (' + awsTrunc(String(value)) + ')' : String(value);
|
|
351
|
+
if (format === 'date'){ const n = Number(value); return Number.isFinite(n) ? new Date(n*1000).toISOString() : String(value); }
|
|
352
|
+
if (format === 'duration'){ const n = Number(value); return Number.isFinite(n) ? n + 's' : String(value); }
|
|
353
|
+
return String(value);
|
|
354
|
+
}
|
|
355
|
+
function awsDescribeCall(ctx){
|
|
356
|
+
const { signature, args } = ctx; const { name } = awsParseSignature(signature);
|
|
357
|
+
const sym = ctx.symbol || 'tokens'; const dec = ctx.decimals != null ? ctx.decimals : 18;
|
|
358
|
+
if (name === 'transfer' && args.length >= 2)
|
|
359
|
+
return { title: 'Send ' + awsFormatAmount(String(args[1]), dec) + ' ' + sym + ' to ' + awsTrunc(String(args[0])),
|
|
360
|
+
fields: [{label:'To', value:String(args[0])}, {label:'Amount', value: awsFormatAmount(String(args[1]),dec)+' '+sym}] };
|
|
361
|
+
if (name === 'transferFrom' && args.length >= 3)
|
|
362
|
+
return { title: 'Send ' + awsFormatAmount(String(args[2]), dec) + ' ' + sym + ' from ' + awsTrunc(String(args[0])) + ' to ' + awsTrunc(String(args[1])),
|
|
363
|
+
fields: [{label:'From', value:String(args[0])}, {label:'To', value:String(args[1])}, {label:'Amount', value: awsFormatAmount(String(args[2]),dec)+' '+sym}] };
|
|
364
|
+
if (name === 'approve' && args.length >= 2){
|
|
365
|
+
const unlimited = awsIsUnlimited(String(args[1]));
|
|
366
|
+
const amt = unlimited ? 'UNLIMITED ' + sym : awsFormatAmount(String(args[1]), dec) + ' ' + sym;
|
|
367
|
+
return { title: 'Approve ' + awsTrunc(String(args[0])) + ' to spend ' + amt,
|
|
368
|
+
fields: [{label:'Spender', value:String(args[0])}, {label:'Allowance', value: amt, danger: unlimited}] };
|
|
369
|
+
}
|
|
370
|
+
if (name === 'setApprovalForAll' && args.length >= 2 && (args[1] === true || args[1] === 'true'))
|
|
371
|
+
return { title: 'Allow ' + awsTrunc(String(args[0])) + ' to transfer ALL your NFTs',
|
|
372
|
+
fields: [{label:'Operator', value:String(args[0])}, {label:'Access', value:'ALL NFTs in this collection', danger:true}] };
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
`;
|
|
376
|
+
|
|
377
|
+
// โโ HTML builder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
378
|
+
|
|
379
|
+
export function buildHtml(req, port, networkUrl, opts = {}) {
|
|
380
|
+
const autoTunnel = !!opts.autoTunnel;
|
|
381
|
+
|
|
382
|
+
const CHAINS = {
|
|
383
|
+
1: { name: 'Ethereum', explorer: 'https://etherscan.io', rpc: 'https://eth.llamarpc.com', symbol: 'ETH' },
|
|
384
|
+
10: { name: 'Optimism', explorer: 'https://optimistic.etherscan.io', rpc: 'https://mainnet.optimism.io', symbol: 'ETH' },
|
|
385
|
+
56: { name: 'BNB Chain', explorer: 'https://bscscan.com', rpc: 'https://bsc-dataseed.binance.org', symbol: 'BNB' },
|
|
386
|
+
100: { name: 'Gnosis', explorer: 'https://gnosisscan.io', rpc: 'https://rpc.gnosischain.com', symbol: 'xDAI' },
|
|
387
|
+
130: { name: 'Unichain', explorer: 'https://uniscan.xyz', rpc: 'https://mainnet.unichain.org', symbol: 'ETH' },
|
|
388
|
+
137: { name: 'Polygon', explorer: 'https://polygonscan.com', rpc: 'https://polygon-rpc.com', symbol: 'POL' },
|
|
389
|
+
480: { name: 'World Chain', explorer: 'https://worldscan.org', rpc: 'https://worldchain-mainnet.g.alchemy.com/public', symbol: 'ETH' },
|
|
390
|
+
5000: { name: 'Mantle', explorer: 'https://explorer.mantle.xyz', rpc: 'https://rpc.mantle.xyz', symbol: 'MNT' },
|
|
391
|
+
8453: { name: 'Base', explorer: 'https://basescan.org', rpc: 'https://mainnet.base.org', symbol: 'ETH' },
|
|
392
|
+
42161: { name: 'Arbitrum One', explorer: 'https://arbiscan.io', rpc: 'https://arb1.arbitrum.io/rpc', symbol: 'ETH' },
|
|
393
|
+
42220: { name: 'Celo', explorer: 'https://celoscan.io', rpc: 'https://forno.celo.org', symbol: 'CELO' },
|
|
394
|
+
43114: { name: 'Avalanche', explorer: 'https://snowtrace.io', rpc: 'https://api.avax.network/ext/bc/C/rpc', symbol: 'AVAX' },
|
|
395
|
+
81457: { name: 'Blast', explorer: 'https://blastscan.io', rpc: 'https://rpc.blast.io', symbol: 'ETH' },
|
|
396
|
+
7777777: { name: 'Zora', explorer: 'https://explorer.zora.energy', rpc: 'https://rpc.zora.energy', symbol: 'ETH' },
|
|
397
|
+
// โโ Testnets โโ
|
|
398
|
+
11155111:{ name: 'Sepolia', explorer: 'https://sepolia.etherscan.io', rpc: 'https://ethereum-sepolia-rpc.publicnode.com', symbol: 'ETH' },
|
|
399
|
+
17000: { name: 'Holesky', explorer: 'https://holesky.etherscan.io', rpc: 'https://ethereum-holesky-rpc.publicnode.com', symbol: 'ETH' },
|
|
400
|
+
560048: { name: 'Hoodi', explorer: 'https://hoodi.etherscan.io', rpc: 'https://ethereum-hoodi-rpc.publicnode.com', symbol: 'ETH' },
|
|
401
|
+
84532: { name: 'Base Sepolia', explorer: 'https://sepolia.basescan.org', rpc: 'https://sepolia.base.org', symbol: 'ETH' },
|
|
402
|
+
11155420:{ name: 'Optimism Sepolia', explorer: 'https://sepolia-optimism.etherscan.io', rpc: 'https://sepolia.optimism.io', symbol: 'ETH' },
|
|
403
|
+
421614: { name: 'Arbitrum Sepolia', explorer: 'https://sepolia.arbiscan.io', rpc: 'https://sepolia-rollup.arbitrum.io/rpc', symbol: 'ETH' },
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return `<!DOCTYPE html>
|
|
407
|
+
<html lang="en">
|
|
408
|
+
<head>
|
|
409
|
+
<meta charset="utf-8"/>
|
|
410
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
411
|
+
<title>Review & sign</title>
|
|
412
|
+
<style>
|
|
413
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
414
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
415
|
+
background: #0f1117; color: #e2e8f0; min-height: 100vh;
|
|
416
|
+
display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
|
417
|
+
.card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px;
|
|
418
|
+
max-width: 480px; width: 100%; padding: 2rem; }
|
|
419
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: #f1f5f9; }
|
|
420
|
+
.desc { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
421
|
+
.summary { background: #12151e; border: 1px solid #2d3148; border-radius: 8px;
|
|
422
|
+
padding: 1rem; margin-bottom: 1.5rem; font-size: 0.8125rem; }
|
|
423
|
+
.row { display: flex; justify-content: space-between; gap: 1rem;
|
|
424
|
+
padding: 0.25rem 0; border-bottom: 1px solid #1e2235; }
|
|
425
|
+
.row:last-child { border-bottom: none; }
|
|
426
|
+
.row-label { color: #64748b; flex-shrink: 0; }
|
|
427
|
+
.row-value { color: #cbd5e1; word-break: break-all; text-align: right; }
|
|
428
|
+
.decode { background: #0d1219; border: 1px solid #1e3a5f; border-radius: 8px;
|
|
429
|
+
padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.8rem; display: none; }
|
|
430
|
+
.decode-title { color: #60a5fa; font-weight: 500; margin-bottom: 0.5rem; }
|
|
431
|
+
.decode-row { display: flex; gap: 1rem; padding: 0.15rem 0; }
|
|
432
|
+
.decode-key { color: #64748b; min-width: 80px; flex-shrink: 0; }
|
|
433
|
+
.decode-val { color: #93c5fd; word-break: break-all; }
|
|
434
|
+
.copy-mini { width: auto; background: none; border: none; color: #64748b; cursor: pointer;
|
|
435
|
+
padding: 0 0 0 0.35rem; font-size: 0.8rem; vertical-align: baseline; }
|
|
436
|
+
.copy-mini:hover { color: #60a5fa; opacity: 1; }
|
|
437
|
+
button { width: 100%; padding: 0.75rem; border-radius: 8px; border: none;
|
|
438
|
+
font-size: 0.9375rem; font-weight: 600; cursor: pointer;
|
|
439
|
+
background: #3b82f6; color: #fff; transition: opacity 0.15s; }
|
|
440
|
+
button:hover:not(:disabled) { opacity: 0.9; }
|
|
441
|
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
442
|
+
.spinner { display: inline-block; width: 14px; height: 14px;
|
|
443
|
+
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
|
444
|
+
border-radius: 50%; animation: spin 0.7s linear infinite;
|
|
445
|
+
margin-right: 8px; vertical-align: middle; }
|
|
446
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
447
|
+
.alert { margin-top: 1.25rem; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.875rem; }
|
|
448
|
+
.alert-success { background: #052e16; border: 1px solid #166534; color: #86efac; }
|
|
449
|
+
.alert-error { background: #2d0a0a; border: 1px solid #7f1d1d; color: #fca5a5; }
|
|
450
|
+
.hash-link { color: #60a5fa; word-break: break-all; }
|
|
451
|
+
.card-header { display: flex; justify-content: space-between; align-items: flex-start;
|
|
452
|
+
gap: 1rem; margin-bottom: 0.5rem; }
|
|
453
|
+
.card-header h1 { margin-bottom: 0; }
|
|
454
|
+
.raw-details { margin: 1rem 0 1.75rem; font-size: 0.8rem; }
|
|
455
|
+
.raw-details summary { cursor: pointer; color: #64748b; user-select: none; }
|
|
456
|
+
.raw-details summary:hover { color: #94a3b8; }
|
|
457
|
+
.raw-details pre { margin-top: 0.5rem; background: #0d1219; border: 1px solid #1e2235;
|
|
458
|
+
border-radius: 8px; padding: 0.75rem; overflow-x: auto; color: #cbd5e1;
|
|
459
|
+
white-space: pre-wrap; word-break: break-all; }
|
|
460
|
+
/* Small auto-width buttons override the full-width default */
|
|
461
|
+
.icon-btn { width: auto; padding: 0.4rem 0.7rem; font-size: 0.75rem; font-weight: 500;
|
|
462
|
+
background: #2d3148; color: #cbd5e1; border-radius: 6px; flex-shrink: 0;
|
|
463
|
+
white-space: nowrap; }
|
|
464
|
+
.icon-btn:hover:not(:disabled) { background: #3a3f5a; opacity: 1; }
|
|
465
|
+
.row-value.copyable { cursor: pointer; transition: color 0.15s; }
|
|
466
|
+
.row-value.copyable:hover { color: #60a5fa; }
|
|
467
|
+
.row-value.copyable::after { content: ' โง'; color: #64748b; font-size: 0.75em; }
|
|
468
|
+
.network-info { margin-top: 0.75rem; padding: 0.6rem 0.875rem; border-radius: 8px;
|
|
469
|
+
background: #0d1a2e; border: 1px solid #1e3a5f; font-size: 0.8rem;
|
|
470
|
+
color: #64748b; display: flex; align-items: center; gap: 0.5rem; }
|
|
471
|
+
.network-info:first-of-type { margin-top: 1.25rem; }
|
|
472
|
+
.network-info a { color: #60a5fa; text-decoration: none; word-break: break-all; flex: 1; }
|
|
473
|
+
.network-info a:hover { text-decoration: underline; }
|
|
474
|
+
.network-info.tunnel { background: #0d1e16; border-color: #1e5f3a; }
|
|
475
|
+
.network-info.tunnel a { color: #4ade80; }
|
|
476
|
+
.cross-device { margin-top: 1.25rem; }
|
|
477
|
+
.net-caveat { margin-top: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 8px;
|
|
478
|
+
background: #2a1e08; border: 1px solid #5f4a1e; color: #fbbf24;
|
|
479
|
+
font-size: 0.75rem; line-height: 1.4; }
|
|
480
|
+
/* State visibility: sections with [data-show] are hidden unless body[data-state] matches */
|
|
481
|
+
[data-show] { display: none; }
|
|
482
|
+
body[data-state="ready"] [data-show="ready"] { display: block; }
|
|
483
|
+
body[data-state="wrong-chain"] [data-show="ready"] { display: block; }
|
|
484
|
+
body[data-state="waiting"] [data-show="ready"] { display: block; }
|
|
485
|
+
body[data-state="done"] [data-show="done"] { display: block; }
|
|
486
|
+
body[data-state="error"] [data-show="error"] { display: block; }
|
|
487
|
+
body[data-state="no-wallet"] [data-show="no-wallet"] { display: block; }
|
|
488
|
+
</style>
|
|
489
|
+
</head>
|
|
490
|
+
<body data-state="ready">
|
|
491
|
+
<div class="card">
|
|
492
|
+
|
|
493
|
+
<div data-show="ready">
|
|
494
|
+
<div class="card-header">
|
|
495
|
+
<h1 id="headline">Review transaction</h1>
|
|
496
|
+
<button class="icon-btn" id="copy-all-btn" title="Copy the raw transaction">โง Copy raw tx</button>
|
|
497
|
+
</div>
|
|
498
|
+
<div id="what" class="summary" style="display:none"></div>
|
|
499
|
+
<details class="raw-details">
|
|
500
|
+
<summary>Details</summary>
|
|
501
|
+
<pre id="raw-tx"></pre>
|
|
502
|
+
</details>
|
|
503
|
+
<button id="btn">Connect Wallet</button>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<div data-show="waiting" style="display:none"></div>
|
|
507
|
+
|
|
508
|
+
<div data-show="done">
|
|
509
|
+
<h1>✓ Done</h1>
|
|
510
|
+
<p class="desc" style="margin-top:0.5rem">You can close this tab.</p>
|
|
511
|
+
<div class="alert alert-success" id="done-msg"></div>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
<div data-show="error">
|
|
515
|
+
<h1>Something went wrong</h1>
|
|
516
|
+
<div class="alert alert-error" id="error-msg" style="margin-top:1rem"></div>
|
|
517
|
+
<button style="margin-top:1rem" id="retry-btn">Try again</button>
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
<div data-show="no-wallet">
|
|
521
|
+
<h1>No Wallet Detected</h1>
|
|
522
|
+
<p class="desc" style="margin-top:0.5rem">
|
|
523
|
+
Install a browser wallet extension (MetaMask, Rabby, Coinbase Wallet, etc.) and reload this page.
|
|
524
|
+
</p>
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<div class="cross-device">
|
|
528
|
+
<button class="icon-btn" id="cross-device-btn">๐ฑ Sign on another device</button>
|
|
529
|
+
<div id="cd-panel" style="display:none">
|
|
530
|
+
<div class="network-info tunnel" id="cd-tunnel-row" style="display:none">
|
|
531
|
+
<span>๐ Open on your device:</span>
|
|
532
|
+
<a id="cd-tunnel-link" href="#" target="_blank"></a>
|
|
533
|
+
<button class="icon-btn" id="copy-tunnel-btn">โง Copy</button>
|
|
534
|
+
</div>
|
|
535
|
+
<button class="icon-btn" id="cd-check-btn" style="display:none">Check reachability</button>
|
|
536
|
+
<div id="cd-status" class="net-caveat" style="display:none"></div>
|
|
537
|
+
${networkUrl ? `<div class="network-info">
|
|
538
|
+
<span>๐ฑ Same network:</span>
|
|
539
|
+
<a href="${escHtml(networkUrl)}" target="_blank">${escHtml(networkUrl)}</a>
|
|
540
|
+
<button class="icon-btn" id="copy-url-btn">โง Copy</button>
|
|
541
|
+
</div>` : ''}
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
</div>
|
|
546
|
+
<script type="module">
|
|
547
|
+
// โโ Injected by CLI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
548
|
+
const REQUEST = ${JSON.stringify(req).replace(/<\/script>/gi, '<\\/script>')};
|
|
549
|
+
const RESULT_URL = '/result';
|
|
550
|
+
const AUTO_TUNNEL = ${autoTunnel};
|
|
551
|
+
const CHAIN_META = ${JSON.stringify(CHAINS).replace(/<\/script>/gi, '<\\/script>')};
|
|
552
|
+
|
|
553
|
+
// โโ Pure decode helpers (shared with node tests) โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
554
|
+
${DECODE_HELPERS_JS}
|
|
555
|
+
|
|
556
|
+
// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
557
|
+
const setState = s => { document.body.dataset.state = s; };
|
|
558
|
+
const hex = n => '0x' + n.toString(16);
|
|
559
|
+
|
|
560
|
+
// Copy text to clipboard, with a fallback for insecure (plain http/LAN) contexts
|
|
561
|
+
// where navigator.clipboard is unavailable. Flashes "โ Copied" on the button.
|
|
562
|
+
async function copyText(text, btn) {
|
|
563
|
+
try {
|
|
564
|
+
await navigator.clipboard.writeText(text);
|
|
565
|
+
} catch {
|
|
566
|
+
const ta = document.createElement('textarea');
|
|
567
|
+
ta.value = text;
|
|
568
|
+
ta.style.position = 'fixed';
|
|
569
|
+
ta.style.opacity = '0';
|
|
570
|
+
document.body.appendChild(ta);
|
|
571
|
+
ta.select();
|
|
572
|
+
try { document.execCommand('copy'); } catch {}
|
|
573
|
+
document.body.removeChild(ta);
|
|
574
|
+
}
|
|
575
|
+
if (btn) {
|
|
576
|
+
const orig = btn.textContent;
|
|
577
|
+
btn.textContent = 'โ Copied';
|
|
578
|
+
setTimeout(() => { btn.textContent = orig; }, 1200);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Format a wei value (hex string) as a decimal ETH amount, trimming trailing zeros.
|
|
583
|
+
function formatEther(weiHex) {
|
|
584
|
+
const wei = BigInt(weiHex || '0x0');
|
|
585
|
+
const ONE = 1000000000000000000n;
|
|
586
|
+
const whole = wei / ONE;
|
|
587
|
+
const frac = wei % ONE;
|
|
588
|
+
if (frac === 0n) return whole.toString();
|
|
589
|
+
const fracStr = frac.toString().padStart(18, '0').replace(/0+$/, '');
|
|
590
|
+
return whole.toString() + '.' + fracStr;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Human-readable JSON of the full request, for the "Copy all" button.
|
|
594
|
+
function txDataText() {
|
|
595
|
+
const meta = CHAIN_META[REQUEST.chainId] || {};
|
|
596
|
+
const out = { chainId: REQUEST.chainId, chain: meta.name || \`Chain \${REQUEST.chainId}\` };
|
|
597
|
+
if (REQUEST._type === 'sendTransaction') {
|
|
598
|
+
if (REQUEST.to) out.to = REQUEST.to;
|
|
599
|
+
out.value = REQUEST.value || '0x0';
|
|
600
|
+
out.valueEth = formatEther(REQUEST.value) + ' ' + (meta.symbol || 'ETH');
|
|
601
|
+
if (REQUEST.data) out.data = REQUEST.data;
|
|
602
|
+
if (REQUEST.gas) out.gas = REQUEST.gas;
|
|
603
|
+
} else if (REQUEST._type === 'signTypedData') {
|
|
604
|
+
out.typedData = REQUEST.typedData;
|
|
605
|
+
} else {
|
|
606
|
+
out.message = REQUEST.message;
|
|
607
|
+
}
|
|
608
|
+
return JSON.stringify(out, null, 2);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// โโ Summary table โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
612
|
+
// โโ Post result back to CLI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
613
|
+
async function postResult(data) {
|
|
614
|
+
await fetch(RESULT_URL, {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: { 'content-type': 'application/json' },
|
|
617
|
+
body: JSON.stringify(data),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// โโ Signing logic (implemented in Task 6) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
622
|
+
function showError(msg) {
|
|
623
|
+
setState('error');
|
|
624
|
+
document.getElementById('error-msg').textContent = msg;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function onConnect() {
|
|
628
|
+
const btn = document.getElementById('btn');
|
|
629
|
+
btn.disabled = true;
|
|
630
|
+
try {
|
|
631
|
+
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
|
632
|
+
const account = accounts[0];
|
|
633
|
+
const currentChainHex = await window.ethereum.request({ method: 'eth_chainId' });
|
|
634
|
+
if (Number(currentChainHex) !== REQUEST.chainId) {
|
|
635
|
+
// Trigger the network switch automatically โ no extra button click.
|
|
636
|
+
// The wallet still shows its own approval prompt for the switch.
|
|
637
|
+
await switchChain(account);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
await sign(account);
|
|
641
|
+
} catch (e) {
|
|
642
|
+
showError(e.message || String(e));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function switchChain(account) {
|
|
647
|
+
const btn = document.getElementById('btn');
|
|
648
|
+
const chainName = (CHAIN_META[REQUEST.chainId] || {}).name || 'the required network';
|
|
649
|
+
btn.disabled = true;
|
|
650
|
+
btn.innerHTML = \`<span class="spinner"></span>Switch to \${chainName} in your walletโฆ\`;
|
|
651
|
+
try {
|
|
652
|
+
await window.ethereum.request({
|
|
653
|
+
method: 'wallet_switchEthereumChain',
|
|
654
|
+
params: [{ chainId: hex(REQUEST.chainId) }],
|
|
655
|
+
});
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (err.code === 4902) {
|
|
658
|
+
const meta = CHAIN_META[REQUEST.chainId];
|
|
659
|
+
if (!meta?.rpc) { showError('Unknown chain โ add it manually in your wallet.'); return; }
|
|
660
|
+
try {
|
|
661
|
+
await window.ethereum.request({
|
|
662
|
+
method: 'wallet_addEthereumChain',
|
|
663
|
+
params: [{
|
|
664
|
+
chainId: hex(REQUEST.chainId),
|
|
665
|
+
chainName: meta.name,
|
|
666
|
+
nativeCurrency: { name: meta.symbol, symbol: meta.symbol, decimals: 18 },
|
|
667
|
+
rpcUrls: [meta.rpc],
|
|
668
|
+
blockExplorerUrls: meta.explorer ? [meta.explorer] : [],
|
|
669
|
+
}],
|
|
670
|
+
});
|
|
671
|
+
} catch (addErr) {
|
|
672
|
+
showError(addErr.message || 'Failed to add chain');
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
showError(err.message || 'Chain switch failed');
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
await sign(account);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function buildTx(account) {
|
|
684
|
+
const tx = {
|
|
685
|
+
type: '0x2',
|
|
686
|
+
from: account,
|
|
687
|
+
chainId: hex(REQUEST.chainId),
|
|
688
|
+
value: REQUEST.value || '0x0',
|
|
689
|
+
};
|
|
690
|
+
if (REQUEST.to) tx.to = REQUEST.to;
|
|
691
|
+
if (REQUEST.data) tx.data = REQUEST.data;
|
|
692
|
+
|
|
693
|
+
// Gas limit
|
|
694
|
+
if (REQUEST.gas) {
|
|
695
|
+
tx.gas = REQUEST.gas;
|
|
696
|
+
} else {
|
|
697
|
+
const est = await window.ethereum.request({ method: 'eth_estimateGas', params: [tx] });
|
|
698
|
+
tx.gas = hex(Math.ceil(Number(est) * 1.2));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// EIP-1559 fees
|
|
702
|
+
if (REQUEST.maxFeePerGas && REQUEST.maxPriorityFeePerGas) {
|
|
703
|
+
tx.maxFeePerGas = REQUEST.maxFeePerGas;
|
|
704
|
+
tx.maxPriorityFeePerGas = REQUEST.maxPriorityFeePerGas;
|
|
705
|
+
} else {
|
|
706
|
+
// Best-effort fee estimation. Some wallets/RPCs don't expose
|
|
707
|
+
// eth_maxPriorityFeePerGas โ fall back gracefully, and if we can't
|
|
708
|
+
// estimate at all, omit fee fields entirely so the wallet fills them in.
|
|
709
|
+
try {
|
|
710
|
+
const block = await window.ethereum.request({
|
|
711
|
+
method: 'eth_getBlockByNumber', params: ['latest', false],
|
|
712
|
+
});
|
|
713
|
+
const baseFee = BigInt(block.baseFeePerGas);
|
|
714
|
+
|
|
715
|
+
let priorityFee;
|
|
716
|
+
try {
|
|
717
|
+
priorityFee = BigInt(await window.ethereum.request({ method: 'eth_maxPriorityFeePerGas' }));
|
|
718
|
+
} catch {
|
|
719
|
+
priorityFee = 1500000000n; // 1.5 gwei default tip
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
tx.maxPriorityFeePerGas = hex(priorityFee);
|
|
723
|
+
tx.maxFeePerGas = hex(baseFee * 2n + priorityFee);
|
|
724
|
+
} catch {
|
|
725
|
+
// Couldn't estimate โ let the wallet populate gas fees on its own.
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return tx;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function sign(account) {
|
|
732
|
+
const btn = document.getElementById('btn');
|
|
733
|
+
setState('waiting');
|
|
734
|
+
btn.disabled = true;
|
|
735
|
+
btn.innerHTML = '<span class="spinner"></span>Check your walletโฆ';
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
let result;
|
|
739
|
+
if (REQUEST._type === 'signTypedData') {
|
|
740
|
+
const sig = await window.ethereum.request({
|
|
741
|
+
method: 'eth_signTypedData_v4',
|
|
742
|
+
params: [account, JSON.stringify(REQUEST.typedData)],
|
|
743
|
+
});
|
|
744
|
+
result = { signature: sig, chainId: REQUEST.chainId };
|
|
745
|
+
|
|
746
|
+
} else if (REQUEST._type === 'personalSign') {
|
|
747
|
+
const msgHex = '0x' + Array.from(new TextEncoder().encode(REQUEST.message))
|
|
748
|
+
.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
749
|
+
const sig = await window.ethereum.request({
|
|
750
|
+
method: 'personal_sign',
|
|
751
|
+
params: [msgHex, account],
|
|
752
|
+
});
|
|
753
|
+
result = { signature: sig, chainId: REQUEST.chainId };
|
|
754
|
+
|
|
755
|
+
} else {
|
|
756
|
+
const tx = await buildTx(account);
|
|
757
|
+
const hash = await window.ethereum.request({ method: 'eth_sendTransaction', params: [tx] });
|
|
758
|
+
result = { hash, chainId: REQUEST.chainId };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
await postResult(result);
|
|
762
|
+
setState('done');
|
|
763
|
+
|
|
764
|
+
const doneMsg = document.getElementById('done-msg');
|
|
765
|
+
if (result.hash) {
|
|
766
|
+
const meta = CHAIN_META[REQUEST.chainId];
|
|
767
|
+
const explorerUrl = meta?.explorer ? \`\${meta.explorer}/tx/\${result.hash}\` : null;
|
|
768
|
+
doneMsg.innerHTML = explorerUrl
|
|
769
|
+
? \`Transaction: <a class="hash-link" href="\${explorerUrl}" target="_blank">\${result.hash}</a>\`
|
|
770
|
+
: \`Transaction: \${result.hash}\`;
|
|
771
|
+
} else {
|
|
772
|
+
doneMsg.textContent = \`Signature: \${result.signature}\`;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Best-effort auto-close. Browsers only let a script close a tab it opened
|
|
776
|
+
// itself, so this silently no-ops for OS-launched tabs โ the "you can close
|
|
777
|
+
// this tab" fallback message stays visible in that case.
|
|
778
|
+
setTimeout(() => { try { window.close(); } catch {} }, 1500);
|
|
779
|
+
|
|
780
|
+
} catch (e) {
|
|
781
|
+
showError(e.message || String(e));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// โโ Calldata decoding (implemented in Task 7) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
786
|
+
|
|
787
|
+
// Manual ABI decoder for fixed-size slot types.
|
|
788
|
+
// Covers the most common parameter types (address, uint*, bool, bytes32).
|
|
789
|
+
// Dynamic types (string, bytes[], tuples) fall back to "(complex type)".
|
|
790
|
+
// โโ "What this does": decode the transaction from its own data โโโโโโโโโโโโโโ
|
|
791
|
+
// Layered & best-effort: ERC-7730 descriptor โ resolved-signature semantic
|
|
792
|
+
// render (4byte/openchain + viem) โ generic decoded call โ raw. Any failure
|
|
793
|
+
// falls through; signing is never blocked. The wallet's own review is the
|
|
794
|
+
// final backstop.
|
|
795
|
+
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
796
|
+
|
|
797
|
+
// Single combined section: headline (the plain-English action) on top, then one
|
|
798
|
+
// detail table โ Chain once, recipient once (copyable), amount once, danger
|
|
799
|
+
// flags, and gas if explicitly provided. No duplicated rows.
|
|
800
|
+
function showWhat(title, fields) {
|
|
801
|
+
if (title) document.getElementById('headline').textContent = title;
|
|
802
|
+
const meta = CHAIN_META[REQUEST.chainId] || {};
|
|
803
|
+
const rows = [{ label: 'Chain', value: meta.name || ('Chain ' + REQUEST.chainId) }, ...fields];
|
|
804
|
+
if (REQUEST._type === 'sendTransaction' && REQUEST.gas)
|
|
805
|
+
rows.push({ label: 'Gas limit', value: Number(BigInt(REQUEST.gas)).toLocaleString() });
|
|
806
|
+
const isAddr = v => /^0x[0-9a-fA-F]{40}$/.test(String(v));
|
|
807
|
+
const el = document.getElementById('what');
|
|
808
|
+
el.style.display = 'block';
|
|
809
|
+
el.innerHTML = rows.map(f => {
|
|
810
|
+
const addr = isAddr(f.value);
|
|
811
|
+
const copyBtn = addr ? ' <button class="copy-mini" data-copy="' + esc(String(f.value)) + '" title="Copy address">โง</button>' : '';
|
|
812
|
+
return '<div class="decode-row"><span class="decode-key">' + esc(f.label) + '</span>' +
|
|
813
|
+
'<span class="decode-val"' + (f.danger ? ' style="color:#fca5a5"' : '') + '>' +
|
|
814
|
+
esc(String(f.value)) + copyBtn + '</span></div>';
|
|
815
|
+
}).join('');
|
|
816
|
+
el.querySelectorAll('.copy-mini').forEach(b => b.addEventListener('click', () => copyText(b.dataset.copy, b)));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function resolveSignature(selector) {
|
|
820
|
+
try {
|
|
821
|
+
const { loaders } = await import('https://esm.sh/@shazow/whatsabi');
|
|
822
|
+
const lookup = new loaders.OpenChainSignatureLookup();
|
|
823
|
+
const sigs = await lookup.loadFunctions(selector);
|
|
824
|
+
return (sigs || []).map(s => typeof s === 'string' ? s : (s.name || String(s)));
|
|
825
|
+
} catch { return []; }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function decodeArgs(signature, data) {
|
|
829
|
+
const { parseAbiItem, decodeFunctionData } = await import('https://esm.sh/viem');
|
|
830
|
+
const abi = [ parseAbiItem('function ' + signature) ];
|
|
831
|
+
const { args } = decodeFunctionData({ abi, data });
|
|
832
|
+
return (args || []).map(a => typeof a === 'bigint' ? a.toString() : a);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function tokenMeta(addr) {
|
|
836
|
+
try {
|
|
837
|
+
const { decodeAbiParameters } = await import('https://esm.sh/viem');
|
|
838
|
+
const symHex = await window.ethereum.request({ method: 'eth_call', params: [{ to: addr, data: '0x95d89b41' }, 'latest'] });
|
|
839
|
+
const decHex = await window.ethereum.request({ method: 'eth_call', params: [{ to: addr, data: '0x313ce567' }, 'latest'] });
|
|
840
|
+
let symbol = null, decimals = 18;
|
|
841
|
+
try { symbol = decodeAbiParameters([{ type: 'string' }], symHex)[0]; } catch {}
|
|
842
|
+
try { decimals = Number(decodeAbiParameters([{ type: 'uint8' }], decHex)[0]); } catch {}
|
|
843
|
+
return { symbol, decimals };
|
|
844
|
+
} catch { return { symbol: null, decimals: 18 }; }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function resolvePath(args, path) {
|
|
848
|
+
if (path == null) return '';
|
|
849
|
+
let key = String(path);
|
|
850
|
+
if (key[0] === '#') key = key.slice(1);
|
|
851
|
+
if (key[0] === '.') key = key.slice(1);
|
|
852
|
+
if (args && typeof args === 'object' && !Array.isArray(args) && key in args) return args[key];
|
|
853
|
+
const n = Number(key);
|
|
854
|
+
if (Number.isInteger(n) && Array.isArray(args)) return args[n];
|
|
855
|
+
return Array.isArray(args) ? args.join(', ') : String(args);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function findDescriptorPath(idx, chainId, to) {
|
|
859
|
+
// The 'to' arg is lowercased by the caller. Registry index keys are often
|
|
860
|
+
// EIP-55 checksummed, so match addresses case-insensitively.
|
|
861
|
+
try {
|
|
862
|
+
if (Array.isArray(idx)) {
|
|
863
|
+
const hit = idx.find(e => String(e.chainId) === String(chainId) && (e.address || '').toLowerCase() === to);
|
|
864
|
+
return (hit && (hit.path || hit.file)) || null;
|
|
865
|
+
}
|
|
866
|
+
const byChain = idx[String(chainId)] || idx[chainId] || idx;
|
|
867
|
+
if (byChain && typeof byChain === 'object') {
|
|
868
|
+
for (const [k, v] of Object.entries(byChain)) {
|
|
869
|
+
if (k.toLowerCase() === to) return (v && (v.path || v.file)) || v || null;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
} catch {}
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function tryCalldataDescriptor(chainId, addr, data) {
|
|
877
|
+
try {
|
|
878
|
+
const idx = await (await fetch(awsDescriptorIndexUrl('calldata'))).json();
|
|
879
|
+
const path = findDescriptorPath(idx, chainId, String(addr).toLowerCase());
|
|
880
|
+
if (!path) return null;
|
|
881
|
+
const base = 'https://raw.githubusercontent.com/ethereum/clear-signing-erc7730-registry/master/registry/';
|
|
882
|
+
const descriptor = await (await fetch(base + path)).json();
|
|
883
|
+
const { decodeFunctionData } = await import('https://esm.sh/viem');
|
|
884
|
+
const abi = descriptor.context && descriptor.context.contract && descriptor.context.contract.abi;
|
|
885
|
+
if (!abi || !data) return null;
|
|
886
|
+
const { functionName, args } = decodeFunctionData({ abi, data });
|
|
887
|
+
const formats = (descriptor.display && descriptor.display.formats) || {};
|
|
888
|
+
const fmt = formats[functionName] || formats[data.slice(0, 10)];
|
|
889
|
+
if (!fmt) return null;
|
|
890
|
+
const fields = (fmt.fields || []).map(f => ({
|
|
891
|
+
label: f.label || f.path,
|
|
892
|
+
value: awsFormatDescriptorField(f.format, resolvePath(args, f.path), f.params || {}),
|
|
893
|
+
}));
|
|
894
|
+
return { title: fmt.intent || functionName, fields };
|
|
895
|
+
} catch { return null; }
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function renderWhatThisDoes() {
|
|
899
|
+
document.getElementById('headline').textContent = awsPlaceholderTitle(REQUEST._type);
|
|
900
|
+
|
|
901
|
+
if (REQUEST._type === 'personalSign') {
|
|
902
|
+
showWhat('Sign message', [{ label: 'Message', value: REQUEST.message }]);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (REQUEST._type === 'signTypedData') {
|
|
906
|
+
const td = REQUEST.typedData || {};
|
|
907
|
+
const rows = Object.entries(td.message || {}).map(([k, v]) =>
|
|
908
|
+
({ label: k, value: typeof v === 'object' ? JSON.stringify(v) : String(v) }));
|
|
909
|
+
showWhat('Sign ' + (td.primaryType || 'typed data'),
|
|
910
|
+
rows.length ? rows : [{ label: 'Type', value: td.primaryType || '(unknown)' }]);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const { to, data, value } = REQUEST;
|
|
915
|
+
|
|
916
|
+
// Contract deployment
|
|
917
|
+
if (!to && data && data.length > 2) {
|
|
918
|
+
const bytes = Math.floor((data.length - 2) / 2);
|
|
919
|
+
showWhat('Deploy a new contract', [{ label: 'Bytecode', value: bytes + ' bytes' }]);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Native send (no calldata)
|
|
924
|
+
if (!data || data.length < 10) {
|
|
925
|
+
const sym = (CHAIN_META[REQUEST.chainId] || {}).symbol || 'ETH';
|
|
926
|
+
const amt = awsFormatAmount(BigInt(value || '0x0').toString(), 18) + ' ' + sym;
|
|
927
|
+
if (to) showWhat('Send ' + amt + ' to ' + awsTrunc(to), [{ label: 'To', value: to }, { label: 'Amount', value: amt }]);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// 1) ERC-7730 descriptor (protocol-authored)
|
|
932
|
+
const desc7730 = await tryCalldataDescriptor(REQUEST.chainId, to, data);
|
|
933
|
+
if (desc7730) { showWhat(desc7730.title, desc7730.fields); return; }
|
|
934
|
+
|
|
935
|
+
// 2) Resolved-signature semantic render
|
|
936
|
+
const sigs = await resolveSignature(data.slice(0, 10));
|
|
937
|
+
if (!sigs.length) {
|
|
938
|
+
showWhat(null, [{ label: 'Call', value: 'Unknown function ' + data.slice(0, 10) + ' โ confirm in your wallet' }]);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const signature = sigs[0];
|
|
942
|
+
let args;
|
|
943
|
+
try { args = await decodeArgs(signature, data); }
|
|
944
|
+
catch {
|
|
945
|
+
showWhat('Calls ' + awsParseSignature(signature).name + '()',
|
|
946
|
+
[{ label: 'Signature', value: signature }, { label: 'Note', value: 'Could not decode arguments โ confirm in your wallet' }]);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const { name, types } = awsParseSignature(signature);
|
|
951
|
+
let meta = {};
|
|
952
|
+
if ((name === 'transfer' || name === 'transferFrom' || name === 'approve') && to) meta = await tokenMeta(to);
|
|
953
|
+
const desc = awsDescribeCall({ signature, args, symbol: meta.symbol, decimals: meta.decimals });
|
|
954
|
+
if (desc) { showWhat(desc.title, desc.fields); return; }
|
|
955
|
+
|
|
956
|
+
// 3) Generic decoded call
|
|
957
|
+
showWhat('Calls ' + name + '()', types.map((t, i) => ({ label: t, value: String(args[i] != null ? args[i] : '?') })));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// โโ Cross-device tunnel + copy buttons (work regardless of wallet state) โโโโโ
|
|
961
|
+
function showTunnel(url) {
|
|
962
|
+
document.getElementById('cd-tunnel-link').href = url;
|
|
963
|
+
document.getElementById('cd-tunnel-link').textContent = url;
|
|
964
|
+
document.getElementById('cd-tunnel-row').style.display = 'flex';
|
|
965
|
+
document.getElementById('cd-check-btn').style.display = 'block';
|
|
966
|
+
}
|
|
967
|
+
function showStatus(msg) {
|
|
968
|
+
const el = document.getElementById('cd-status');
|
|
969
|
+
el.textContent = msg;
|
|
970
|
+
el.style.display = 'block';
|
|
971
|
+
}
|
|
972
|
+
async function startTunnel() {
|
|
973
|
+
const btn = document.getElementById('cross-device-btn');
|
|
974
|
+
document.getElementById('cd-panel').style.display = 'block';
|
|
975
|
+
btn.disabled = true;
|
|
976
|
+
btn.innerHTML = '<span class="spinner"></span>Starting secure tunnelโฆ';
|
|
977
|
+
try {
|
|
978
|
+
const res = await fetch('/tunnel/start', { method: 'POST' }).then(r => r.json());
|
|
979
|
+
if (res.url) {
|
|
980
|
+
showTunnel(res.url);
|
|
981
|
+
btn.style.display = 'none';
|
|
982
|
+
} else {
|
|
983
|
+
showStatus('Could not start a tunnel right now (Cloudflare may be throttled). Use the same-network address below, or try again later.');
|
|
984
|
+
btn.disabled = false;
|
|
985
|
+
btn.textContent = '๐ฑ Sign on another device';
|
|
986
|
+
}
|
|
987
|
+
} catch {
|
|
988
|
+
showStatus('Tunnel request failed. Try again.');
|
|
989
|
+
btn.disabled = false;
|
|
990
|
+
btn.textContent = '๐ฑ Sign on another device';
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async function checkTunnel() {
|
|
994
|
+
const btn = document.getElementById('cd-check-btn');
|
|
995
|
+
btn.disabled = true;
|
|
996
|
+
const orig = btn.textContent;
|
|
997
|
+
btn.innerHTML = '<span class="spinner"></span>Checkingโฆ';
|
|
998
|
+
const { reachable } = await fetch('/tunnel/check', { method: 'POST' }).then(r => r.json());
|
|
999
|
+
btn.disabled = false;
|
|
1000
|
+
btn.textContent = orig;
|
|
1001
|
+
showStatus(reachable
|
|
1002
|
+
? 'โ Tunnel is reachable โ open the link on your other device.'
|
|
1003
|
+
: 'โ Not reachable yet (DNS may still be propagating, or Cloudflare is throttling). Wait a few seconds and check again.');
|
|
1004
|
+
}
|
|
1005
|
+
const cdBtn = document.getElementById('cross-device-btn');
|
|
1006
|
+
if (cdBtn) cdBtn.addEventListener('click', startTunnel);
|
|
1007
|
+
const cdCheck = document.getElementById('cd-check-btn');
|
|
1008
|
+
if (cdCheck) cdCheck.addEventListener('click', checkTunnel);
|
|
1009
|
+
const copyTunnelBtn = document.getElementById('copy-tunnel-btn');
|
|
1010
|
+
if (copyTunnelBtn) copyTunnelBtn.addEventListener('click', e =>
|
|
1011
|
+
copyText(document.getElementById('cd-tunnel-link').href, e.currentTarget));
|
|
1012
|
+
const copyUrlBtn = document.getElementById('copy-url-btn');
|
|
1013
|
+
if (copyUrlBtn) copyUrlBtn.addEventListener('click', e =>
|
|
1014
|
+
copyText(e.currentTarget.closest('.network-info').querySelector('a').getAttribute('href'), e.currentTarget));
|
|
1015
|
+
const copyAllBtn = document.getElementById('copy-all-btn');
|
|
1016
|
+
if (copyAllBtn) copyAllBtn.addEventListener('click', e => copyText(txDataText(), e.currentTarget));
|
|
1017
|
+
if (AUTO_TUNNEL) startTunnel();
|
|
1018
|
+
|
|
1019
|
+
// โโ Init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1020
|
+
if (!window.ethereum) {
|
|
1021
|
+
setState('no-wallet');
|
|
1022
|
+
} else {
|
|
1023
|
+
renderWhatThisDoes().catch(() => {});
|
|
1024
|
+
document.getElementById('raw-tx').textContent = txDataText();
|
|
1025
|
+
document.getElementById('btn').addEventListener('click', onConnect);
|
|
1026
|
+
document.getElementById('retry-btn').addEventListener('click', () => {
|
|
1027
|
+
setState('ready');
|
|
1028
|
+
document.getElementById('btn').disabled = false;
|
|
1029
|
+
document.getElementById('btn').textContent = 'Connect Wallet';
|
|
1030
|
+
document.getElementById('btn').onclick = onConnect;
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
</script>
|
|
1034
|
+
</body>
|
|
1035
|
+
</html>`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// HTML-escape helper (used by the full template in Task 5)
|
|
1039
|
+
export function escHtml(str) {
|
|
1040
|
+
return String(str ?? '')
|
|
1041
|
+
.replace(/&/g, '&')
|
|
1042
|
+
.replace(/</g, '<')
|
|
1043
|
+
.replace(/>/g, '>')
|
|
1044
|
+
.replace(/"/g, '"');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// โโ Main orchestration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1048
|
+
|
|
1049
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
1050
|
+
const PREFERRED_PORT = 8456; // stable port enables cloudflared tunnel reuse across runs
|
|
1051
|
+
|
|
1052
|
+
export const HELP_TEXT = `request-wallet-sign โ surface a wallet signing request to a user via a browser page
|
|
1053
|
+
|
|
1054
|
+
USAGE
|
|
1055
|
+
npx request-wallet-sign [--tunnel] '<request JSON>'
|
|
1056
|
+
npx request-wallet-sign --stop-tunnel
|
|
1057
|
+
|
|
1058
|
+
OPTIONS
|
|
1059
|
+
--tunnel Pre-start the cross-device HTTPS tunnel as soon as the page
|
|
1060
|
+
loads. Otherwise the tunnel starts when you click "Sign on
|
|
1061
|
+
another device" in the page. Cross-device signing needs this
|
|
1062
|
+
HTTPS tunnel because mobile wallets only inject a provider
|
|
1063
|
+
over HTTPS โ a plain http://LAN-IP address will not work.
|
|
1064
|
+
--stop-tunnel Tear down the shared background cloudflared tunnel and exit.
|
|
1065
|
+
--help, -h Show this help.
|
|
1066
|
+
|
|
1067
|
+
The request JSON is a single argument. Operation type is inferred:
|
|
1068
|
+
typedData โ eth_signTypedData_v4 ยท message โ personal_sign ยท otherwise โ eth_sendTransaction
|
|
1069
|
+
|
|
1070
|
+
Cross-device tunnels are REUSED across invocations (recorded in
|
|
1071
|
+
~/.request-wallet-sign/state.json) so signing many transactions does not create
|
|
1072
|
+
many tunnels. The shared tunnel is one background process, reaped after 10
|
|
1073
|
+
minutes idle or immediately with --stop-tunnel.
|
|
1074
|
+
|
|
1075
|
+
On success prints {"hash"|"signature", "chainId"} to stdout and exits 0.
|
|
1076
|
+
On rejection / no wallet / 5-min timeout prints to stderr and exits 1.
|
|
1077
|
+
`;
|
|
1078
|
+
|
|
1079
|
+
export async function run(argv) {
|
|
1080
|
+
if (argv.slice(2).some(a => a === '--help' || a === '-h')) {
|
|
1081
|
+
process.stdout.write(HELP_TEXT);
|
|
1082
|
+
process.exit(0);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const opts = parseOptions(argv);
|
|
1086
|
+
|
|
1087
|
+
if (opts.stopTunnel) {
|
|
1088
|
+
const url = stopTunnel();
|
|
1089
|
+
process.stderr.write(url ? `stopped tunnel ${url}\n` : 'no tunnel was running\n');
|
|
1090
|
+
process.exit(0);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
let req;
|
|
1094
|
+
try {
|
|
1095
|
+
req = parseRequest(argv);
|
|
1096
|
+
} catch (e) {
|
|
1097
|
+
process.stderr.write(e.message + '\n');
|
|
1098
|
+
process.exit(1);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const port = await choosePort(PREFERRED_PORT);
|
|
1102
|
+
const networkIP = getLocalNetworkIP();
|
|
1103
|
+
const networkUrl = networkIP ? `http://${networkIP}:${port}` : null;
|
|
1104
|
+
|
|
1105
|
+
const tunnel = createTunnelController(port, { log: m => process.stderr.write(`tunnel: ${m}\n`) });
|
|
1106
|
+
const html = buildHtml(req, port, networkUrl, { autoTunnel: opts.tunnel });
|
|
1107
|
+
const { result, close } = startServer(port, html, tunnel);
|
|
1108
|
+
|
|
1109
|
+
const timeout = setTimeout(() => {
|
|
1110
|
+
close();
|
|
1111
|
+
process.stderr.write('timeout: user did not respond\n');
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}, TIMEOUT_MS);
|
|
1114
|
+
|
|
1115
|
+
if (networkUrl) process.stderr.write(`same-network URL: ${networkUrl}\n`);
|
|
1116
|
+
process.stderr.write('cross-device: use the "Sign on another device" button in the page\n');
|
|
1117
|
+
openBrowser(`http://localhost:${port}`);
|
|
1118
|
+
|
|
1119
|
+
result
|
|
1120
|
+
.then(data => {
|
|
1121
|
+
clearTimeout(timeout);
|
|
1122
|
+
close(); // NOTE: do NOT kill the tunnel โ it persists for reuse
|
|
1123
|
+
process.stdout.write(JSON.stringify(data) + '\n');
|
|
1124
|
+
process.exit(0);
|
|
1125
|
+
})
|
|
1126
|
+
.catch(e => {
|
|
1127
|
+
clearTimeout(timeout);
|
|
1128
|
+
close();
|
|
1129
|
+
process.stderr.write((e.message ?? String(e)) + '\n');
|
|
1130
|
+
process.exit(1);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// โโ Entry point โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1135
|
+
// Only execute when run directly (not when imported by tests).
|
|
1136
|
+
|
|
1137
|
+
const isMain = process.argv[1] &&
|
|
1138
|
+
new URL(import.meta.url).pathname === process.argv[1];
|
|
1139
|
+
|
|
1140
|
+
if (isMain) {
|
|
1141
|
+
run(process.argv);
|
|
1142
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "request-wallet-sign",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Let AI agents surface wallet signing requests to users via a local browser page",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": ">=18" },
|
|
7
|
+
"bin": { "request-wallet-sign": "./bin/index.js" },
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node --test",
|
|
10
|
+
"prepublishOnly": "node --test"
|
|
11
|
+
},
|
|
12
|
+
"files": ["bin/index.js"],
|
|
13
|
+
"keywords": ["wallet", "ethereum", "signing", "agent", "cli", "clear-signing", "erc-7730"],
|
|
14
|
+
"author": "Elliott Alexander",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": { "type": "git", "url": "git+https://github.com/escottalexander/agent-wallet-signer.git" },
|
|
17
|
+
"homepage": "https://github.com/escottalexander/agent-wallet-signer#readme",
|
|
18
|
+
"bugs": { "url": "https://github.com/escottalexander/agent-wallet-signer/issues" }
|
|
19
|
+
}
|