@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.
@@ -0,0 +1,198 @@
1
+ import { Interface, getAddress, formatUnits } from 'ethers';
2
+ import { isErc20Selector } from './contractAllowList.js';
3
+
4
+ const AAVE_V3_POOL_ABI = [
5
+ 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)',
6
+ 'function withdraw(address asset, uint256 amount, address to) returns (uint256)',
7
+ 'function borrow(address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf)',
8
+ 'function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) returns (uint256)',
9
+ 'function repayWithATokens(address asset, uint256 amount, uint256 interestRateMode) returns (uint256)',
10
+ 'function setUserUseReserveAsCollateral(address asset, bool useAsCollateral)',
11
+ 'function setUserEMode(uint8 categoryId)',
12
+ 'function liquidationCall(address collateralAsset, address debtAsset, address user, uint256 debtToCover, bool receiveAToken)',
13
+ ];
14
+
15
+ const ERC20_ABI = [
16
+ 'function approve(address spender, uint256 amount)',
17
+ 'function transfer(address to, uint256 amount)',
18
+ 'function transferFrom(address from, address to, uint256 amount)',
19
+ ];
20
+
21
+ const VURTO_DUAL_ADAPTER_ABI = [
22
+ 'function executeDualSwap(tuple(address fromAsset,address toAsset,uint256 fromAmount,uint256 minToAmount,address aggregator,address spender,bytes swapData) coll, tuple(address fromAsset,address toAsset,uint256 fromAmount,uint256 minToAmount,address aggregator,address spender,bytes swapData) debt, uint256 deadline)',
23
+ ];
24
+
25
+ const ABIS = [
26
+ { iface: new Interface(AAVE_V3_POOL_ABI), scope: 'aave-pool' },
27
+ { iface: new Interface(ERC20_ABI), scope: 'erc20' },
28
+ { iface: new Interface(VURTO_DUAL_ADAPTER_ABI), scope: 'vurto-adapter' },
29
+ ];
30
+
31
+ export const MAX_UINT256 = (1n << 256n) - 1n;
32
+
33
+ const KNOWN_TOKENS = new Map(Object.entries({
34
+ '1:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { symbol: 'USDC', decimals: 6 },
35
+ '1:0xdac17f958d2ee523a2206206994597c13d831ec7': { symbol: 'USDT', decimals: 6 },
36
+ '1:0x6b175474e89094c44da98b954eedeac495271d0f': { symbol: 'DAI', decimals: 18 },
37
+ '1:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599': { symbol: 'WBTC', decimals: 8 },
38
+ '1:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': { symbol: 'WETH', decimals: 18 },
39
+ '10:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { symbol: 'USDC', decimals: 6 },
40
+ '10:0x94b008aa00579c1307b0ef2c499ad98a8ce58e58': { symbol: 'USDT', decimals: 6 },
41
+ '10:0x4200000000000000000000000000000000000006': { symbol: 'WETH', decimals: 18 },
42
+ '137:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { symbol: 'USDC', decimals: 6 },
43
+ '137:0xc2132d05d31c914a87c6611c10748aeb04b58e8f': { symbol: 'USDT', decimals: 6 },
44
+ '137:0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270': { symbol: 'WPOL', decimals: 18 },
45
+ '8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { symbol: 'USDC', decimals: 6 },
46
+ '8453:0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42': { symbol: 'EURC', decimals: 6 },
47
+ '8453:0x4200000000000000000000000000000000000006': { symbol: 'WETH', decimals: 18 },
48
+ '42161:0xaf88d065e77c8cc2239327c5edb3a432268e5831': { symbol: 'USDC', decimals: 6 },
49
+ '42161:0xff970a61a04b1ca14834a43f5de4533ebddb5cc8': { symbol: 'USDC.E', decimals: 6 },
50
+ '42161:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9': { symbol: 'USDT', decimals: 6 },
51
+ '42161:0x82af49447d8a07e3bd95bd0d56f35241523fbab1': { symbol: 'WETH', decimals: 18 },
52
+ '56:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d': { symbol: 'USDC', decimals: 18 },
53
+ '56:0x55d398326f99059ff775485246999027b3197955': { symbol: 'USDT', decimals: 18 },
54
+ '100:0xddafbb505ad214d7b80b1f830fccc89b60fb7a83': { symbol: 'USDC', decimals: 6 },
55
+ '100:0xcb444e90d8198415266c6a2724b7900fb12fc56e': { symbol: 'EURe', decimals: 18 },
56
+ '43114:0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e': { symbol: 'USDC', decimals: 6 },
57
+ '146:0x29219dd400f2bf60e5a23d13be72b486d4038894': { symbol: 'USDC', decimals: 6 },
58
+ }));
59
+
60
+ export function lookupToken(chainId, address) {
61
+ if (typeof address !== 'string') return null;
62
+ return KNOWN_TOKENS.get(`${Number(chainId)}:${address.toLowerCase()}`) || null;
63
+ }
64
+
65
+ function tryParse(data, value) {
66
+ for (const { iface, scope } of ABIS) {
67
+ try {
68
+ const parsed = iface.parseTransaction({ data, value: value || '0x0' });
69
+ if (parsed) return { parsed, scope };
70
+ } catch {}
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function formatParam(arg, frag, chainId, contextAsset) {
76
+ const type = frag.type;
77
+ if (type === 'address') {
78
+ let checksummed = String(arg);
79
+ try { checksummed = getAddress(checksummed); } catch {}
80
+ const token = lookupToken(chainId, checksummed);
81
+ return {
82
+ type,
83
+ raw: checksummed,
84
+ display: token ? `${checksummed} (${token.symbol})` : checksummed,
85
+ };
86
+ }
87
+ if (type.startsWith('uint') || type.startsWith('int')) {
88
+ const big = typeof arg === 'bigint' ? arg : BigInt(arg.toString());
89
+ let display = big.toString();
90
+ let isUnlimited = false;
91
+ if (big === MAX_UINT256) {
92
+ display = 'MAX_UINT256 (unlimited)';
93
+ isUnlimited = true;
94
+ } else if (contextAsset && contextAsset.decimals != null) {
95
+ try {
96
+ const human = formatUnits(big, contextAsset.decimals);
97
+ display = `${human} ${contextAsset.symbol} (raw: ${big.toString()})`;
98
+ } catch {}
99
+ }
100
+ return { type, raw: big.toString(), display, isUnlimited };
101
+ }
102
+ if (type === 'bool') return { type, raw: arg, display: arg ? 'true' : 'false' };
103
+ if (type === 'bytes' || type.startsWith('bytes')) {
104
+ const s = String(arg);
105
+ const short = s.length > 66 ? `${s.slice(0, 32)}…${s.slice(-10)} (${(s.length - 2) / 2} bytes)` : s;
106
+ return { type, raw: s, display: short };
107
+ }
108
+ return { type, raw: arg, display: JSON.stringify(arg, (_k, v) => typeof v === 'bigint' ? v.toString() : v) };
109
+ }
110
+
111
+ export function decodeTx({ to, data, value, chainId }) {
112
+ const safeData = typeof data === 'string' && data.length >= 10 ? data : '0x';
113
+ const result = {
114
+ decoded: false,
115
+ selector: safeData.length >= 10 ? safeData.slice(0, 10) : null,
116
+ name: null,
117
+ signature: null,
118
+ scope: null,
119
+ params: [],
120
+ notes: [],
121
+ isApprove: false,
122
+ isUnlimitedApprove: false,
123
+ approvedToken: null,
124
+ approvedSpender: null,
125
+ approvedAmount: null,
126
+ };
127
+
128
+ if (safeData === '0x' || safeData.length < 10) {
129
+ if (value && BigInt(value) > 0n) {
130
+ result.notes.push('Plain value transfer (no calldata).');
131
+ } else {
132
+ result.notes.push('Empty calldata — likely a self-call or contract destruction (rare).');
133
+ }
134
+ return result;
135
+ }
136
+
137
+ const erc20Hint = isErc20Selector(safeData);
138
+ let contextAsset = erc20Hint ? lookupToken(chainId, to) : null;
139
+
140
+ const attempt = tryParse(safeData, value);
141
+ if (!attempt) {
142
+ result.notes.push(`Unknown function selector ${result.selector}. Inspect the raw calldata carefully.`);
143
+ return result;
144
+ }
145
+
146
+ const { parsed, scope } = attempt;
147
+ result.decoded = true;
148
+ result.name = parsed.name;
149
+ result.signature = parsed.signature;
150
+ result.scope = scope;
151
+
152
+ const params = parsed.fragment.inputs.map((frag, i) => {
153
+ const isAmountField = ['amount', 'fromAmount', 'minToAmount', 'value'].includes(frag.name);
154
+ const assetForAmount = isAmountField ? contextAsset : null;
155
+ return {
156
+ name: frag.name || `arg${i}`,
157
+ ...formatParam(parsed.args[i], frag, chainId, assetForAmount),
158
+ };
159
+ });
160
+
161
+ if (scope === 'aave-pool' && ['supply', 'withdraw', 'borrow', 'repay', 'repayWithATokens'].includes(parsed.name)) {
162
+ const assetParam = params.find((p) => p.name === 'asset');
163
+ if (assetParam) {
164
+ const tokenInfo = lookupToken(chainId, assetParam.raw);
165
+ if (tokenInfo) {
166
+ const amtParam = params.find((p) => p.name === 'amount');
167
+ if (amtParam && /^\d+$/.test(amtParam.raw) && !amtParam.isUnlimited) {
168
+ try {
169
+ amtParam.display = `${formatUnits(BigInt(amtParam.raw), tokenInfo.decimals)} ${tokenInfo.symbol} (raw: ${amtParam.raw})`;
170
+ } catch {}
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ result.params = params;
177
+
178
+ if (scope === 'erc20' && parsed.name === 'approve') {
179
+ result.isApprove = true;
180
+ const spenderArg = params.find((p) => p.name === 'spender');
181
+ const amountArg = params.find((p) => p.name === 'amount');
182
+ result.approvedToken = contextAsset
183
+ ? { address: to, ...contextAsset }
184
+ : { address: to, symbol: 'token', decimals: null };
185
+ result.approvedSpender = spenderArg ? spenderArg.raw : null;
186
+ result.approvedAmount = amountArg ? amountArg.raw : null;
187
+ if (amountArg && amountArg.isUnlimited) {
188
+ result.isUnlimitedApprove = true;
189
+ } else if (amountArg && contextAsset && contextAsset.decimals != null) {
190
+ try {
191
+ const human = Number(formatUnits(BigInt(amountArg.raw), contextAsset.decimals));
192
+ if (human >= 1_000_000_000) result.isUnlimitedApprove = true;
193
+ } catch {}
194
+ }
195
+ }
196
+
197
+ return result;
198
+ }
@@ -0,0 +1,33 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { platform } from 'node:os';
3
+
4
+ export function openBrowser(url) {
5
+ return new Promise((resolve, reject) => {
6
+ const p = platform();
7
+ let cmd;
8
+ let args;
9
+ let opts = { stdio: 'ignore', detached: true };
10
+
11
+ if (p === 'darwin') {
12
+ cmd = 'open';
13
+ args = [url];
14
+ } else if (p === 'win32') {
15
+ cmd = 'cmd';
16
+ args = ['/c', 'start', '""', url.replace(/&/g, '^&')];
17
+ } else {
18
+ cmd = 'xdg-open';
19
+ args = [url];
20
+ }
21
+
22
+ let child;
23
+ try {
24
+ child = spawn(cmd, args, opts);
25
+ } catch (err) {
26
+ reject(err);
27
+ return;
28
+ }
29
+ child.on('error', reject);
30
+ child.unref?.();
31
+ resolve();
32
+ });
33
+ }
package/src/server.js ADDED
@@ -0,0 +1,139 @@
1
+ import http from 'node:http';
2
+ import crypto from 'node:crypto';
3
+ import { buildSignerHtml } from './signerHtml.js';
4
+
5
+ const TX_HASH_RE = /^0x[0-9a-f]{64}$/i;
6
+ const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
7
+ const MAX_BODY_BYTES = 16 * 1024;
8
+
9
+ export function startSignerServer({ payload, port = 0, timeoutMs = 180_000, onListen }) {
10
+ return new Promise((resolve) => {
11
+ const scriptNonce = crypto.randomBytes(16).toString('base64');
12
+ const styleNonce = crypto.randomBytes(16).toString('base64');
13
+ const sessionToken = crypto.randomBytes(24).toString('hex');
14
+
15
+ let consumed = false;
16
+ let timer = null;
17
+
18
+ const html = buildSignerHtml({
19
+ payload,
20
+ scriptNonce,
21
+ styleNonce,
22
+ sessionToken,
23
+ timeoutMs,
24
+ });
25
+ const cspHeader =
26
+ `default-src 'none'; ` +
27
+ `script-src 'self' 'nonce-${scriptNonce}'; ` +
28
+ `style-src 'self' 'nonce-${styleNonce}'; ` +
29
+ `connect-src 'self'; ` +
30
+ `img-src 'self' data:; ` +
31
+ `frame-ancestors 'none'; ` +
32
+ `form-action 'none'; ` +
33
+ `base-uri 'none';`;
34
+
35
+ const server = http.createServer((req, res) => {
36
+ const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
37
+
38
+ const send = (status, headers, body) => {
39
+ res.writeHead(status, {
40
+ 'cache-control': 'no-store',
41
+ 'x-content-type-options': 'nosniff',
42
+ 'referrer-policy': 'no-referrer',
43
+ ...headers,
44
+ });
45
+ res.end(body);
46
+ };
47
+
48
+ if (consumed) {
49
+ send(410, { 'content-type': 'text/plain; charset=utf-8' }, 'Gone — this signer is single-use.');
50
+ return;
51
+ }
52
+
53
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
54
+ send(200, {
55
+ 'content-type': 'text/html; charset=utf-8',
56
+ 'content-security-policy': cspHeader,
57
+ }, html);
58
+ return;
59
+ }
60
+
61
+ if (req.method === 'POST' && url.pathname === '/result') {
62
+ const provided = req.headers['x-session-token'];
63
+ if (provided !== sessionToken) {
64
+ send(403, { 'content-type': 'application/json' }, JSON.stringify({ ok: false, error: 'forbidden' }));
65
+ return;
66
+ }
67
+ let raw = '';
68
+ let aborted = false;
69
+ req.on('data', (chunk) => {
70
+ raw += chunk;
71
+ if (raw.length > MAX_BODY_BYTES) {
72
+ aborted = true;
73
+ send(413, { 'content-type': 'application/json' }, JSON.stringify({ ok: false, error: 'payload too large' }));
74
+ req.destroy();
75
+ }
76
+ });
77
+ req.on('end', () => {
78
+ if (aborted) return;
79
+ let body;
80
+ try { body = JSON.parse(raw || '{}'); } catch {
81
+ send(400, { 'content-type': 'application/json' }, JSON.stringify({ ok: false, error: 'invalid json' }));
82
+ return;
83
+ }
84
+
85
+ if (body && body.rejected === true) {
86
+ consumed = true;
87
+ send(200, { 'content-type': 'application/json' }, JSON.stringify({ ok: true }));
88
+ finish({ kind: 'rejected' });
89
+ return;
90
+ }
91
+
92
+ if (body && typeof body.txHash === 'string' && TX_HASH_RE.test(body.txHash)) {
93
+ const from = typeof body.from === 'string' && ADDR_RE.test(body.from) ? body.from : payload.walletAddress;
94
+ consumed = true;
95
+ send(200, { 'content-type': 'application/json' }, JSON.stringify({ ok: true }));
96
+ finish({
97
+ kind: 'signed',
98
+ txHash: body.txHash,
99
+ from,
100
+ signedAt: new Date().toISOString(),
101
+ });
102
+ return;
103
+ }
104
+
105
+ send(400, { 'content-type': 'application/json' }, JSON.stringify({ ok: false, error: 'invalid result body' }));
106
+ });
107
+ req.on('error', () => {});
108
+ return;
109
+ }
110
+
111
+ send(404, { 'content-type': 'text/plain; charset=utf-8' }, 'Not found');
112
+ });
113
+
114
+ const finish = (result) => {
115
+ if (timer) { clearTimeout(timer); timer = null; }
116
+ setTimeout(() => {
117
+ try { server.close(); } catch {}
118
+ resolve(result);
119
+ }, 50);
120
+ };
121
+
122
+ server.on('error', (err) => {
123
+ finish({ kind: 'error', message: `server error: ${err.message}` });
124
+ });
125
+
126
+ server.listen(port, '127.0.0.1', () => {
127
+ const addr = server.address();
128
+ const url = `http://127.0.0.1:${addr.port}/`;
129
+ timer = setTimeout(() => {
130
+ if (consumed) return;
131
+ consumed = true;
132
+ finish({ kind: 'timeout' });
133
+ }, timeoutMs);
134
+ if (typeof onListen === 'function') {
135
+ try { onListen({ url, port: addr.port }); } catch {}
136
+ }
137
+ });
138
+ });
139
+ }