@vurto/sign-tx 0.1.0-stage.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # @vurto/sign-tx
2
+
3
+ Local browser-signer CLI for the Vurto AnA MCP hard-wallet flow.
4
+
5
+ Your AI agent never sees your private key. When an MCP tool prepares a transaction (supply, repay, withdraw, borrow, swap, zap, …), it returns the calldata plus a short summary. Pipe that into `sign-tx`, and this CLI spins up a **single-use local page on `127.0.0.1`** that connects to your MetaMask / Rabby / Ledger / Trezor and asks **you** to sign. The transaction hash is printed to stdout so the agent can pick it back up and continue the workflow.
6
+
7
+ The page works fully offline from Vurto's network — every asset is inlined, the Content-Security-Policy denies all external loads.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g @vurto/sign-tx
13
+ ```
14
+
15
+ You can also run it ad hoc without installing:
16
+
17
+ ```bash
18
+ npx @vurto/sign-tx --tx '<json>'
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ sign-tx --tx '{
25
+ "txData": {
26
+ "to": "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5",
27
+ "data": "0x617ba037...",
28
+ "chainId": 8453,
29
+ "value": "0x0"
30
+ },
31
+ "summary": {
32
+ "action": "supply",
33
+ "humanDescription": "Supply 100 USDC on Base to Aave V3"
34
+ },
35
+ "walletAddress": "0xYourWalletHere",
36
+ "transactionId": 17
37
+ }'
38
+ ```
39
+
40
+ What happens:
41
+
42
+ 1. `sign-tx` binds an HTTP server on `127.0.0.1` (free port chosen by the OS).
43
+ 2. The system browser opens the signer page.
44
+ 3. You connect MetaMask / Rabby (the page validates wallet and chain).
45
+ 4. You inspect the decoded calldata, gas estimate, and destination contract.
46
+ 5. You click **Sign** (or **Reject**).
47
+ 6. `sign-tx` prints the result JSON to stdout and exits.
48
+
49
+ ### Result on stdout
50
+
51
+ ```json
52
+ {
53
+ "txHash": "0xabc...",
54
+ "from": "0xYour...",
55
+ "chainId": 8453,
56
+ "signedAt": "2026-05-27T12:34:56.000Z"
57
+ }
58
+ ```
59
+
60
+ ### Exit codes
61
+
62
+ | Code | Meaning |
63
+ |------|--------------------------------------------|
64
+ | 0 | Transaction signed and broadcast |
65
+ | 1 | User rejected the request |
66
+ | 2 | Timed out waiting for approval |
67
+ | 3 | Validation error (bad flags or bad JSON) |
68
+
69
+ ## Flags
70
+
71
+ | Flag | Default | Description |
72
+ |-----------------------|---------|-------------|
73
+ | `--tx <json>` | — | Inline JSON payload. Required unless you pipe via stdin. |
74
+ | `--port <n>` | `0` | TCP port on `127.0.0.1`. `0` = OS picks a free port. |
75
+ | `--no-open` | off | Do not auto-open the browser; just print the URL on stderr. |
76
+ | `--timeout <seconds>` | `180` | Approval timeout. After this, the CLI exits with code 2. |
77
+ | `--report-to <url>` | — | Optional HTTPS endpoint to POST the result to (e.g. an MCP webhook). |
78
+ | `--help` | — | Print help. |
79
+ | `--version` | — | Print version. |
80
+
81
+ You can also pipe the JSON instead of using `--tx`:
82
+
83
+ ```bash
84
+ cat tx.json | sign-tx --no-open
85
+ ```
86
+
87
+ ## Safety mitigations
88
+
89
+ - **127.0.0.1 only.** The HTTP server never binds to a public interface.
90
+ - **Single-use session.** A random session token is required for `POST /result`. After a result is delivered, every route returns 410 Gone.
91
+ - **Strict CSP.** `default-src 'none'`; scripts and styles whitelisted by nonce; no external loads possible. No cross-origin frames, no form submissions.
92
+ - **Allow-list of destinations.** Vurto Dual-Aave Adapter, Aave V3 Pool, ParaSwap Augustus V6.2 and TokenTransferProxy across 9 chains. Unknown destinations show a red banner; you can still sign, but only after acknowledging it.
93
+ - **Wallet match.** The connected `eth_accounts[0]` must equal the expected `walletAddress`. Sign is disabled otherwise.
94
+ - **Chain match.** The wallet's current `chainId` must equal the payload's `chainId`. `sign-tx` triggers `wallet_switchEthereumChain` automatically; if you reject the switch, Sign stays disabled.
95
+ - **Unlimited-approval guard.** `approve(spender, MAX_UINT256)` (or any amount > 1B raw units of a known stablecoin) shows a red card with the spender address and forces you to tick "I understand I'm approving an unlimited amount of {TOKEN}" before Sign enables.
96
+ - **Decoded calldata first, raw second.** The page leads with the human-readable summary and decoded named parameters. The raw `to`/`data`/`value` triplet lives behind a collapsible.
97
+
98
+ ## Sigstore verification
99
+
100
+ Releases are signed with [sigstore/cosign](https://docs.sigstore.dev/). To verify a published tarball:
101
+
102
+ ```bash
103
+ # Download the tarball + bundle from the GitHub release page, then:
104
+ cosign verify-blob \
105
+ --bundle vurto-sign-tx-0.1.0.tgz.sigstore \
106
+ --certificate-identity-regexp 'https://github\.com/vurto-cc/vurto-a/\.github/workflows/.*' \
107
+ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
108
+ vurto-sign-tx-0.1.0.tgz
109
+ ```
110
+
111
+ A successful verify means the tarball was built by the canonical GitHub Actions workflow in `vurto-cc/vurto-a` — no human can sneak in a malicious build.
112
+
113
+ ## Dev usage from a checkout
114
+
115
+ If you're hacking on this CLI inside the `vurto-a` repo:
116
+
117
+ ```bash
118
+ cd tools/sign-cli
119
+ npm install
120
+ node bin/sign-tx.js --help
121
+ node --test tests/
122
+ ```
123
+
124
+ A handy smoke test (will time out after 5s with exit code 2):
125
+
126
+ ```bash
127
+ node bin/sign-tx.js --tx '{
128
+ "txData": {
129
+ "to": "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5",
130
+ "data": "0x617ba037000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000001234567890123456789012345678901234567890000000000000000000000000000000000000000000000000000000000000000a",
131
+ "chainId": 42161,
132
+ "value": "0x0"
133
+ },
134
+ "summary": { "action": "supply", "humanDescription": "Supply 100 USDC on Arbitrum to Aave V3" },
135
+ "walletAddress": "0x1234567890123456789012345678901234567890",
136
+ "transactionId": 1
137
+ }' --no-open --timeout 5
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT.
package/bin/sign-tx.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+
4
+ run(process.argv.slice(2)).then((code) => {
5
+ process.exit(code);
6
+ }).catch((err) => {
7
+ process.stderr.write(`sign-tx: fatal: ${err?.stack || err?.message || String(err)}\n`);
8
+ process.exit(3);
9
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@vurto/sign-tx",
3
+ "version": "0.1.0-stage.1",
4
+ "description": "Vurto AnA local browser-signer CLI: opens a single-use page on 127.0.0.1 that lets you sign a prepared transaction with MetaMask/Rabby and returns the tx hash to stdout.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "sign-tx": "./bin/sign-tx.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test tests/",
20
+ "check": "node --check bin/sign-tx.js"
21
+ },
22
+ "dependencies": {
23
+ "ethers": "^6.13.0"
24
+ },
25
+ "keywords": [
26
+ "vurto",
27
+ "aave",
28
+ "ana",
29
+ "mcp",
30
+ "signer",
31
+ "ethereum",
32
+ "metamask",
33
+ "rabby",
34
+ "hardware-wallet"
35
+ ],
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/vurto-cc/vurto-a.git",
39
+ "directory": "tools/sign-cli"
40
+ },
41
+ "homepage": "https://vurto.cc"
42
+ }
package/src/cli.js ADDED
@@ -0,0 +1,238 @@
1
+ import { startSignerServer } from './server.js';
2
+ import { openBrowser } from './openBrowser.js';
3
+
4
+ const USAGE = `@vurto/sign-tx — sign a prepared transaction in your local browser with MetaMask/Rabby.
5
+
6
+ USAGE
7
+ sign-tx --tx '<json>' [options]
8
+ echo '<json>' | sign-tx [options]
9
+
10
+ OPTIONS
11
+ --tx <json> Inline transaction payload (JSON). If omitted, read from stdin.
12
+ --port <number> TCP port to bind on 127.0.0.1 (default: 0 = OS-assigned).
13
+ --no-open Do not auto-open the system browser; just print the URL.
14
+ --timeout <seconds> Approval timeout in seconds (default: 180).
15
+ --report-to <url> Optional HTTPS endpoint that receives a POST with the result.
16
+ --version Print version and exit.
17
+ --help Print this help and exit.
18
+
19
+ EXIT CODES
20
+ 0 transaction signed and broadcast
21
+ 1 user rejected
22
+ 2 timeout
23
+ 3 validation error
24
+
25
+ EXAMPLES
26
+ sign-tx --tx "$(cat tx.json)"
27
+ cat tx.json | sign-tx --no-open --timeout 60
28
+ `;
29
+
30
+ export function parseArgs(argv) {
31
+ const out = {
32
+ tx: null,
33
+ port: 0,
34
+ open: true,
35
+ timeout: 180,
36
+ reportTo: null,
37
+ help: false,
38
+ version: false,
39
+ };
40
+ const errors = [];
41
+
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ switch (a) {
45
+ case '--help':
46
+ case '-h':
47
+ out.help = true;
48
+ break;
49
+ case '--version':
50
+ case '-v':
51
+ out.version = true;
52
+ break;
53
+ case '--no-open':
54
+ out.open = false;
55
+ break;
56
+ case '--tx': {
57
+ const v = argv[++i];
58
+ if (v === undefined) { errors.push('--tx requires a JSON string argument'); break; }
59
+ out.tx = v;
60
+ break;
61
+ }
62
+ case '--port': {
63
+ const v = argv[++i];
64
+ const n = Number(v);
65
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
66
+ errors.push(`--port must be an integer 0..65535 (got: ${v})`);
67
+ } else {
68
+ out.port = n;
69
+ }
70
+ break;
71
+ }
72
+ case '--timeout': {
73
+ const v = argv[++i];
74
+ const n = Number(v);
75
+ if (!Number.isFinite(n) || n <= 0 || n > 3600) {
76
+ errors.push(`--timeout must be a number 1..3600 seconds (got: ${v})`);
77
+ } else {
78
+ out.timeout = n;
79
+ }
80
+ break;
81
+ }
82
+ case '--report-to': {
83
+ const v = argv[++i];
84
+ if (v === undefined) { errors.push('--report-to requires a URL argument'); break; }
85
+ if (!/^https?:\/\//i.test(v)) {
86
+ errors.push(`--report-to must be an http(s) URL (got: ${v})`);
87
+ } else {
88
+ out.reportTo = v;
89
+ }
90
+ break;
91
+ }
92
+ default:
93
+ if (a.startsWith('--')) errors.push(`unknown option: ${a}`);
94
+ else errors.push(`unexpected positional argument: ${a}`);
95
+ }
96
+ }
97
+
98
+ return { opts: out, errors };
99
+ }
100
+
101
+ const TX_HASH_RE = /^0x[0-9a-f]{64}$/i;
102
+ const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
103
+ const HEX_RE = /^0x[0-9a-fA-F]*$/;
104
+
105
+ export function validatePayload(payload) {
106
+ const errors = [];
107
+ if (!payload || typeof payload !== 'object') {
108
+ return ['payload must be a JSON object'];
109
+ }
110
+ const { txData, summary, walletAddress, transactionId } = payload;
111
+ if (!txData || typeof txData !== 'object') errors.push('payload.txData is required');
112
+ else {
113
+ if (!ADDR_RE.test(String(txData.to || ''))) errors.push('payload.txData.to must be a 0x-prefixed 40-hex address');
114
+ if (typeof txData.data !== 'string' || !HEX_RE.test(txData.data)) errors.push('payload.txData.data must be a hex string');
115
+ if (!Number.isInteger(txData.chainId) || txData.chainId <= 0) errors.push('payload.txData.chainId must be a positive integer');
116
+ if (txData.value !== undefined && typeof txData.value !== 'string') errors.push('payload.txData.value must be a hex string when present');
117
+ }
118
+ if (!summary || typeof summary !== 'object') errors.push('payload.summary is required');
119
+ else if (typeof summary.humanDescription !== 'string' || !summary.humanDescription.trim()) {
120
+ errors.push('payload.summary.humanDescription must be a non-empty string');
121
+ }
122
+ if (!ADDR_RE.test(String(walletAddress || ''))) errors.push('payload.walletAddress must be a 0x-prefixed 40-hex address');
123
+ if (transactionId !== undefined && transactionId !== null && !(typeof transactionId === 'string' || Number.isFinite(transactionId))) {
124
+ errors.push('payload.transactionId must be a string or number when present');
125
+ }
126
+ return errors;
127
+ }
128
+
129
+ async function readStdin() {
130
+ if (process.stdin.isTTY) return '';
131
+ return await new Promise((resolve, reject) => {
132
+ let buf = '';
133
+ process.stdin.setEncoding('utf8');
134
+ process.stdin.on('data', (chunk) => { buf += chunk; });
135
+ process.stdin.on('end', () => resolve(buf));
136
+ process.stdin.on('error', reject);
137
+ });
138
+ }
139
+
140
+ export async function run(argv, deps = {}) {
141
+ const stdout = deps.stdout || process.stdout;
142
+ const stderr = deps.stderr || process.stderr;
143
+ const startServerFn = deps.startSignerServer || startSignerServer;
144
+ const openBrowserFn = deps.openBrowser || openBrowser;
145
+ const readStdinFn = deps.readStdin || readStdin;
146
+
147
+ const { opts, errors } = parseArgs(argv);
148
+ if (errors.length) {
149
+ for (const e of errors) stderr.write(`sign-tx: ${e}\n`);
150
+ stderr.write(`\n${USAGE}`);
151
+ return 3;
152
+ }
153
+ if (opts.help) { stdout.write(USAGE); return 0; }
154
+ if (opts.version) {
155
+ stdout.write(`@vurto/sign-tx 0.1.0-stage.1\n`);
156
+ return 0;
157
+ }
158
+
159
+ let txJson = opts.tx;
160
+ if (!txJson) txJson = (await readStdinFn()).trim();
161
+ if (!txJson) {
162
+ stderr.write('sign-tx: missing --tx and no stdin input\n');
163
+ stderr.write(`\n${USAGE}`);
164
+ return 3;
165
+ }
166
+
167
+ let payload;
168
+ try {
169
+ payload = JSON.parse(txJson);
170
+ } catch (e) {
171
+ stderr.write(`sign-tx: invalid JSON payload: ${e.message}\n`);
172
+ return 3;
173
+ }
174
+
175
+ const validation = validatePayload(payload);
176
+ if (validation.length) {
177
+ for (const v of validation) stderr.write(`sign-tx: ${v}\n`);
178
+ return 3;
179
+ }
180
+
181
+ const result = await startServerFn({
182
+ payload,
183
+ port: opts.port,
184
+ timeoutMs: opts.timeout * 1000,
185
+ onListen: ({ url }) => {
186
+ stderr.write(`sign-tx: signer ready at ${url}\n`);
187
+ stderr.write(`sign-tx: waiting up to ${opts.timeout}s for approval...\n`);
188
+ if (opts.open) {
189
+ openBrowserFn(url).catch((e) => {
190
+ stderr.write(`sign-tx: could not auto-open browser (${e.message}); paste the URL manually\n`);
191
+ });
192
+ }
193
+ },
194
+ });
195
+
196
+ if (opts.reportTo && (result.kind === 'signed' || result.kind === 'rejected')) {
197
+ try {
198
+ await fetch(opts.reportTo, {
199
+ method: 'POST',
200
+ headers: { 'content-type': 'application/json' },
201
+ body: JSON.stringify({
202
+ transactionId: payload.transactionId ?? null,
203
+ result,
204
+ }),
205
+ });
206
+ } catch (e) {
207
+ stderr.write(`sign-tx: --report-to delivery failed: ${e.message}\n`);
208
+ }
209
+ }
210
+
211
+ switch (result.kind) {
212
+ case 'signed': {
213
+ if (!TX_HASH_RE.test(result.txHash || '')) {
214
+ stderr.write('sign-tx: signer returned malformed txHash\n');
215
+ return 3;
216
+ }
217
+ stdout.write(JSON.stringify({
218
+ txHash: result.txHash,
219
+ from: result.from,
220
+ chainId: payload.txData.chainId,
221
+ signedAt: result.signedAt,
222
+ }) + '\n');
223
+ return 0;
224
+ }
225
+ case 'rejected':
226
+ stderr.write('sign-tx: user rejected the transaction\n');
227
+ return 1;
228
+ case 'timeout':
229
+ stderr.write('sign-tx: timed out waiting for approval\n');
230
+ return 2;
231
+ case 'error':
232
+ stderr.write(`sign-tx: ${result.message}\n`);
233
+ return 3;
234
+ default:
235
+ stderr.write(`sign-tx: unexpected signer result\n`);
236
+ return 3;
237
+ }
238
+ }
@@ -0,0 +1,94 @@
1
+ const CHAINS = Object.freeze({
2
+ 1: { name: 'Ethereum', explorer: 'https://etherscan.io', symbol: 'ETH' },
3
+ 10: { name: 'Optimism', explorer: 'https://optimistic.etherscan.io', symbol: 'ETH' },
4
+ 56: { name: 'BNB Chain', explorer: 'https://bscscan.com', symbol: 'BNB' },
5
+ 100: { name: 'Gnosis', explorer: 'https://gnosisscan.io', symbol: 'xDAI' },
6
+ 137: { name: 'Polygon', explorer: 'https://polygonscan.com', symbol: 'POL' },
7
+ 146: { name: 'Sonic', explorer: 'https://sonicscan.org', symbol: 'S' },
8
+ 8453: { name: 'Base', explorer: 'https://basescan.org', symbol: 'ETH' },
9
+ 42161: { name: 'Arbitrum', explorer: 'https://arbiscan.io', symbol: 'ETH' },
10
+ 43114: { name: 'Avalanche', explorer: 'https://snowtrace.io', symbol: 'AVAX' },
11
+ });
12
+
13
+ const AAVE_V3_POOL = {
14
+ 1: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2',
15
+ 10: '0x794a61358D6845594F94dc1DB02A252b5b4814aD',
16
+ 56: '0x6807dc923806fE8Fd134338EABCA509979a7e0cB',
17
+ 100: '0xb50201558B00496A145fE76f7424749556E326D8',
18
+ 137: '0x794a61358D6845594F94dc1DB02A252b5b4814aD',
19
+ 146: '0x5362dBb1e601abF3a4c14c22ffEdA64042E5eAA3',
20
+ 8453: '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5',
21
+ 42161: '0x794a61358D6845594F94dc1DB02A252b5b4814aD',
22
+ 43114: '0x794a61358D6845594F94dc1DB02A252b5b4814aD',
23
+ };
24
+
25
+ const VURTO_DUAL_AAVE_ADAPTER = {
26
+ 1: '0x7014309A43860dcEDdC0Ae2cDbDCE16F724e10Da',
27
+ 56: '0x7014309A43860dcEDdC0Ae2cDbDCE16F724e10Da',
28
+ 100: '0x7014309A43860dcEDdC0Ae2cDbDCE16F724e10Da',
29
+ 137: '0x9cCc0B9e7F36c0ae5b95f6d9C522c74f789A0035',
30
+ 8453: '0x009D3f5c5cee523D9005125E2e0EE51878eff21F',
31
+ 42161: '0x9397a49Ee8b4E7B71d38f920fb9F1c8E1b1F4d11',
32
+ };
33
+
34
+ const PARASWAP_AUGUSTUS_V6_2 = {
35
+ 1: '0x6A000F20005980200259B80c5102003040001068',
36
+ 10: '0x6A000F20005980200259B80c5102003040001068',
37
+ 56: '0x6A000F20005980200259B80c5102003040001068',
38
+ 137: '0x6A000F20005980200259B80c5102003040001068',
39
+ 8453: '0x6A000F20005980200259B80c5102003040001068',
40
+ 42161: '0x6A000F20005980200259B80c5102003040001068',
41
+ 43114: '0x6A000F20005980200259B80c5102003040001068',
42
+ };
43
+
44
+ const PARASWAP_TOKEN_TRANSFER_PROXY = {
45
+ 1: '0x6A0009d27Ee0d1A6c8aabb27DDE7e83C5d7C20fF',
46
+ 10: '0x6A0009d27Ee0d1A6c8aabb27DDE7e83C5d7C20fF',
47
+ 56: '0x6A0009d27Ee0d1A6c8aabb27DDE7e83C5d7C20fF',
48
+ 137: '0x6A0009d27Ee0d1A6c8aabb27DDE7e83C5d7C20fF',
49
+ 8453: '0x6A0009d27Ee0d1A6c8aabb27DDE7e83C5d7C20fF',
50
+ 42161: '0x6A0009d27Ee0d1A6c8aabb27DDE7e83C5d7C20fF',
51
+ 43114: '0x6A0009d27Ee0d1A6c8aabb27DDE7e83C5d7C20fF',
52
+ };
53
+
54
+ function lower(addr) {
55
+ return String(addr || '').toLowerCase();
56
+ }
57
+
58
+ function buildAllowMap() {
59
+ const out = new Map();
60
+ const add = (chainId, addr, name, category) => {
61
+ if (!addr) return;
62
+ const key = `${chainId}:${lower(addr)}`;
63
+ out.set(key, { chainId, address: addr, name, category });
64
+ };
65
+ for (const [chainId, addr] of Object.entries(AAVE_V3_POOL)) add(Number(chainId), addr, `Aave V3 Pool (${CHAINS[chainId]?.name || `chain ${chainId}`})`, 'aave-pool');
66
+ for (const [chainId, addr] of Object.entries(VURTO_DUAL_AAVE_ADAPTER)) add(Number(chainId), addr, `Vurto Dual-Aave Adapter (${CHAINS[chainId]?.name || `chain ${chainId}`})`, 'vurto-adapter');
67
+ for (const [chainId, addr] of Object.entries(PARASWAP_AUGUSTUS_V6_2)) add(Number(chainId), addr, `ParaSwap Augustus V6.2 (${CHAINS[chainId]?.name || `chain ${chainId}`})`, 'paraswap-router');
68
+ for (const [chainId, addr] of Object.entries(PARASWAP_TOKEN_TRANSFER_PROXY)) add(Number(chainId), addr, `ParaSwap TokenTransferProxy (${CHAINS[chainId]?.name || `chain ${chainId}`})`, 'paraswap-proxy');
69
+ return out;
70
+ }
71
+
72
+ const ALLOW_MAP = buildAllowMap();
73
+
74
+ export function lookupContract(chainId, address) {
75
+ if (!Number.isInteger(Number(chainId)) || typeof address !== 'string') return null;
76
+ return ALLOW_MAP.get(`${Number(chainId)}:${lower(address)}`) || null;
77
+ }
78
+
79
+ export function getChainInfo(chainId) {
80
+ return CHAINS[Number(chainId)] || null;
81
+ }
82
+
83
+ export function isErc20Selector(data) {
84
+ if (typeof data !== 'string' || data.length < 10) return null;
85
+ const sel = data.slice(0, 10).toLowerCase();
86
+ if (sel === '0x095ea7b3') return 'approve';
87
+ if (sel === '0xa9059cbb') return 'transfer';
88
+ if (sel === '0x23b872dd') return 'transferFrom';
89
+ return null;
90
+ }
91
+
92
+ export function snapshotAllowList() {
93
+ return Array.from(ALLOW_MAP.values());
94
+ }