/* The three non-custody invariants (plan B12) — run via: * npx tsx src/lib/nonCustodyInvariants.test.ts * * xXTrade's no-licence position rests on three facts about the code. Breaking any * of them is a custody / transmission exposure, so each is CI-gated here, both by * behaviour (stubbed network, real signing) and by source shape: * * 1. The agent key is generated on the client, and only its ADDRESS and * signatures ever leave the browser. No server code can mint or receive one. * 2. The gasless deposit relayer can only send to Hyperliquid's Bridge2 * contract. Nothing in a request can redirect it. * 3. Withdrawals and sends are signed by the user's own wallet (EIP-712 * user-signed domain), never by the agent key and never by a server. * 4. (W1 XX-19) End to end: each withdrawal / send goes in ONE request straight * to the exchange, carrying exactly the destination and amount the user * signed; burner (mirror) wallets sign their own sends and only ever send * back to the connected wallet; the agent's one fund-moving action * (vaultTransfer) names nothing but a vault; no server can sign anything. * * Published at https://xxtrade.xyz/non-custody-invariants.txt (linked from * /security); src/lib/securityPage.test.ts keeps that copy identical to this file. * * Part of `npm test` (scripts/run-tests.mjs runs every *.test.ts). */ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { decodeFunctionData, parseAbi, parseTransaction, recoverTypedDataAddress, serializeSignature, type Hex } from 'viem' import { BUILDER_ADDRESS, buildSendAssetTypedData, buildSpotSendTypedData, buildUsdClassTransferTypedData, buildWithdrawTypedData, generateAgent, placeOrder, sendAssetWithKey, submitApproveAgent, submitSendAsset, submitSpotSend, submitUsdClassTransfer, submitWithdraw, usdClassTransferWithKey, vaultTransfer, } from './hlSigning' import { BRIDGE2_ADDRESS } from './hlDeposit' import { mintSessionKey } from './papertrade/session' import { PT_TYPES, openPositionTypedData, closeTypedData } from './papertrade/typedData' import { signWithSessionKey, submitIntent } from './papertrade/client' let failures = 0 function check(name: string, cond: boolean, extra = '') { if (cond) console.log(` ✓ ${name}`) else { failures++; console.log(` ✗ ${name} ${extra}`) } } const ROOT = fileURLToPath(new URL('../..', import.meta.url)) const read = (p: string) => readFileSync(join(ROOT, p), 'utf8') function walk(dir: string, out: string[] = []): string[] { for (const name of readdirSync(join(ROOT, dir))) { if (name === 'node_modules' || name.startsWith('.')) continue const p = `${dir}/${name}` // posix-style even on Windows; the expected lists use / if (statSync(join(ROOT, p)).isDirectory()) walk(p, out) else if (/\.(ts|tsx|js|mjs)$/.test(name) && !/\.test\.ts$/.test(name)) out.push(p) } return out } const SERVER_FILES = [...walk('api'), ...walk('workers')] const CLIENT_FILES = walk('src') /** HL's mainnet Bridge2 on Arbitrum (hyperliquid-docs: Bridge2). */ const CANONICAL_BRIDGE2 = '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7' // ── fetch stub ───────────────────────────────────────────────────────────── type Call = { url: string; body: string } let calls: Call[] = [] let respond: (url: string, body: any) => unknown = () => ({ status: 'ok', response: { type: 'default' } }) const realFetch = globalThis.fetch globalThis.fetch = (async (input: any, init?: any) => { const url = typeof input === 'string' ? input : input?.url ?? String(input) const body = typeof init?.body === 'string' ? init.body : '' calls.push({ url, body }) let parsed: any = null try { parsed = body ? JSON.parse(body) : null } catch { /* not json */ } const out = respond(url, parsed) return new Response(JSON.stringify(out), { status: 200, headers: { 'content-type': 'application/json' } }) }) as typeof fetch const sigOf = (s: { r: Hex; s: Hex; v: number }) => serializeSignature({ r: s.r, s: s.s, v: BigInt(s.v) }) const strip = (h: string) => h.toLowerCase().replace(/^0x/, '') async function main() { const user = privateKeyToAccount(generatePrivateKey()) // ── 1. agent key is generated client-side ──────────────────────────────── console.log('1. agent key is created on the client and never leaves it') { calls = [] const agent = generateAgent(user.address) check('generateAgent makes no network call', calls.length === 0, String(calls.length)) check('it returns a 32-byte private key', /^0x[0-9a-f]{64}$/.test(agent.privateKey)) check('the address is derived locally from that key', privateKeyToAccount(agent.privateKey).address === agent.address) check('two agents never share a key', generateAgent(user.address).privateKey !== agent.privateKey) // Approving the agent sends its ADDRESS, signed by the user. Not the key. calls = [] const nonce = Date.now() await submitApproveAgent('0x' + '11'.repeat(32) + '22'.repeat(32) + '1b' as Hex, agent.address, nonce) const approve = calls[0]?.body ?? '' check('approveAgent posts only to Hyperliquid', calls.length === 1 && calls[0].url === 'https://api.hyperliquid.xyz/exchange', calls.map((c) => c.url).join(',')) check('approveAgent carries the agent address', approve.includes(agent.address)) check('approveAgent never carries the agent private key', !approve.toLowerCase().includes(strip(agent.privateKey))) // Orders are signed locally with the agent key; only the signature travels. calls = [] respond = () => ({ status: 'ok', response: { type: 'order', data: { statuses: [{ resting: { oid: 1 } }] } } }) await placeOrder(agent.privateKey, { orders: [{ a: 0, b: true, p: '100', s: '0.001', r: false, t: { limit: { tif: 'Gtc' } } }], grouping: 'na' }) const sent = calls.map((c) => c.body.toLowerCase()).join('\n') check('an order reaches Hyperliquid', calls.some((c) => c.url === 'https://api.hyperliquid.xyz/exchange')) check('no request ever carries the agent private key', calls.length > 0 && !sent.includes(strip(agent.privateKey))) check('orders go nowhere but Hyperliquid', calls.every((c) => c.url.startsWith('https://api.hyperliquid.xyz/')), calls.map((c) => c.url).join(',')) respond = () => ({ status: 'ok', response: { type: 'default' } }) // Source shape: key minting lives in three client modules only. The third (2026-10-29) // is the SYNTH ULTRA PERPS (Papertrade) session key: it can only open and close // positions on the user's own Papertrade account, never withdraw (section 1b). const minters = CLIENT_FILES.filter((f) => /\bgeneratePrivateKey\b|\bgenerateMnemonic\b/.test(read(f))) check('only hlSigning.ts (agent), multiWallet.ts (user burners) and papertrade/session.ts (trading key) mint keys', JSON.stringify(minters.sort()) === JSON.stringify(['src/lib/hlSigning.ts', 'src/lib/multiWallet.ts', 'src/lib/papertrade/session.ts']), JSON.stringify(minters)) const serverMint = SERVER_FILES.filter((f) => /\bgeneratePrivateKey\b|\bgenerateMnemonic\b|\bmnemonicToAccount\b|xxtrade_hl_agent_/.test(read(f))) check('no server file mints a key or touches stored agents', serverMint.length === 0, JSON.stringify(serverMint)) const serverSigners = SERVER_FILES.filter((f) => /\bprivateKeyToAccount\b/.test(read(f))) check('the only server signer is the relayer, keyed from its own env', JSON.stringify(serverSigners) === JSON.stringify(['api/deposit-relay.ts']) && /const pk = process\.env\.RELAYER_PK/.test(read('api/deposit-relay.ts')), JSON.stringify(serverSigners)) const leaks = CLIENT_FILES.filter((f) => /JSON\.stringify\([^)]*\b(privateKey|agentKey|mnemonic|secretKey)\b/.test(read(f))) check('no client code serialises a private key or seed into a request body', leaks.length === 0, JSON.stringify(leaks)) } // ── 1b. Papertrade session key (SYNTH ULTRA PERPS) stays on the client ─── console.log('\n1b. the Papertrade trading key is created on the client and never leaves it') { calls = [] const key = mintSessionKey(Date.now()) check('mintSessionKey makes no network call', calls.length === 0, String(calls.length)) check('it is a fresh 32-byte key with a locally derived address', /^0x[0-9a-f]{64}$/.test(key.privateKey) && privateKeyToAccount(key.privateKey).address === key.address && mintSessionKey(0).privateKey !== key.privateKey) respond = (url) => (url.includes('op=intent') ? { ok: true, state: 'queued' } : {}) const open = openPositionTypedData({ user: user.address, instrumentId: 0, isLong: true, marginRaw: 10n ** 19n, leverage: 50, nonce: 256n, nowMs: Date.now() }) await submitIntent(open, await signWithSessionKey(key.privateKey, open), key.address) const close = closeTypedData({ user: user.address, positionIds: ['1'], nonce: 257n, nowMs: Date.now() }) await submitIntent(close, await signWithSessionKey(key.privateKey, close), key.address) const sent = calls.map((c) => c.body.toLowerCase()).join('\n') check('open + close each sent one request, to the app\'s own relay', calls.length === 2 && calls.every((c) => c.url === '/api/papertrade?op=intent'), calls.map((c) => c.url).join(',')) check('no request carries the trading key', !sent.includes(strip(key.privateKey))) check('the relay is sent signatures + the signed message only', calls.every((c) => { const b = JSON.parse(c.body); return Object.keys(b).join() === 'action,signed' && ['openPosition', 'closePositions'].includes(b.action) })) // Only three intent types exist in the app (no WithdrawToCore / Stake / Claim), and the // relay allows exactly their three actions. check('only RegisterSessionKey / OpenPosition / Close are built', Object.keys(PT_TYPES).join() === 'RegisterSessionKey,OpenPosition,Close') const relayActions = /const ACTION_TYPE = \{([^}]*)\}/.exec(read('api/papertrade.ts'))?.[1] ?? '' check('the relay allows exactly registerSessionKey / openPosition / closePositions', [...relayActions.matchAll(/(\w+):/g)].map((m) => m[1]).join() === 'registerSessionKey,openPosition,closePositions', relayActions) respond = () => ({ status: 'ok', response: { type: 'default' } }) } // ── 2. relayer destination is Bridge2 ──────────────────────────────────── console.log('\n2. the deposit relayer can only send to Bridge2') { const relaySrc = read('api/deposit-relay.ts') const m = /const BRIDGE2 = '(0x[0-9a-fA-F]{40})'/.exec(relaySrc) check('relayer BRIDGE2 === hlDeposit BRIDGE2_ADDRESS', !!m && m[1] === BRIDGE2_ADDRESS, m?.[1]) check('BRIDGE2 is Hyperliquid\'s mainnet Bridge2', BRIDGE2_ADDRESS === CANONICAL_BRIDGE2) const tos = [...relaySrc.matchAll(/\bto:\s*([A-Za-z0-9_.]+)/g)].map((x) => x[1]) check('every relayer `to:` is the BRIDGE2 constant', tos.length >= 2 && tos.every((t) => t === 'BRIDGE2'), JSON.stringify(tos)) const client = read('src/lib/hlDeposit.ts') check('client permit spender is Bridge2', /spender:\s*BRIDGE2_ADDRESS/.test(client)) check('client self-submit goes to Bridge2', /to:\s*BRIDGE2_ADDRESS/.test(client)) // Behaviour: run the real handler against a stubbed Arbitrum RPC, with a // request that tries to smuggle in another destination. const relayer = privateKeyToAccount(generatePrivateKey()) const prevPk = process.env.RELAYER_PK const prevRpc = process.env.ARBITRUM_RPC_URL process.env.RELAYER_PK = generatePrivateKey() process.env.ARBITRUM_RPC_URL = 'https://rpc.invalid/arb' void relayer const ethCalls: any[] = [] let raw: Hex | null = null respond = (_url, body) => { const one = (req: any) => { const r = (result: unknown) => ({ jsonrpc: '2.0', id: req.id, result }) switch (req.method) { case 'eth_chainId': return r('0xa4b1') case 'eth_call': ethCalls.push(req.params[0]); return r('0x') case 'eth_getTransactionCount': return r('0x0') case 'eth_estimateGas': return r('0x30000') case 'eth_gasPrice': return r('0x989680') case 'eth_maxPriorityFeePerGas': return r('0x0') case 'eth_getBlockByNumber': return r({ number: '0x1', hash: '0x' + '00'.repeat(32), parentHash: '0x' + '00'.repeat(32), timestamp: '0x1', baseFeePerGas: '0x989680', gasLimit: '0x1c9c380', gasUsed: '0x0', transactions: [], difficulty: '0x0', extraData: '0x', logsBloom: '0x' + '00'.repeat(256), miner: '0x' + '00'.repeat(20), nonce: '0x0000000000000000', receiptsRoot: '0x' + '00'.repeat(32), sha3Uncles: '0x' + '00'.repeat(32), size: '0x0', stateRoot: '0x' + '00'.repeat(32), totalDifficulty: '0x0', transactionsRoot: '0x' + '00'.repeat(32), uncles: [] }) case 'eth_sendRawTransaction': raw = req.params[0]; return r('0x' + 'ab'.repeat(32)) default: return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `stub: ${req.method}` } } } } return Array.isArray(body) ? body.map(one) : one(body) } const { default: handler } = await import('../../api/deposit-relay') const ATTACKER = '0x000000000000000000000000000000000000dEaD' const res: any = { statusCode: 0, headers: {} as Record, body: '', setHeader(k: string, v: string) { this.headers[k] = v }, end(b: string) { this.body = b } } await handler({ method: 'POST', body: { to: ATTACKER, destination: ATTACKER, target: ATTACKER, deposit: { user: user.address, usd: '5000000', deadline: String(Math.floor(Date.now() / 1000) + 3600), signature: { r: '0x' + '11'.repeat(32), s: '0x' + '22'.repeat(32), v: 27 }, to: ATTACKER }, }, }, res) respond = () => ({ status: 'ok', response: { type: 'default' } }) process.env.RELAYER_PK = prevPk process.env.ARBITRUM_RPC_URL = prevRpc if (prevPk === undefined) delete process.env.RELAYER_PK if (prevRpc === undefined) delete process.env.ARBITRUM_RPC_URL check('relay accepted the deposit (200)', res.statusCode === 200, `${res.statusCode} ${res.body}`) check('simulation was against Bridge2', ethCalls.length > 0 && ethCalls.every((c) => String(c.to).toLowerCase() === BRIDGE2_ADDRESS.toLowerCase()), JSON.stringify(ethCalls.map((c) => c.to))) check('a transaction was broadcast', raw !== null) if (raw) { const tx = parseTransaction(raw) check('the broadcast transaction goes to Bridge2', tx.to?.toLowerCase() === BRIDGE2_ADDRESS.toLowerCase(), String(tx.to)) check('it carries no ETH value', !tx.value || tx.value === 0n) const abi = parseAbi(['function batchedDepositWithPermit((address user, uint64 usd, uint64 deadline, (uint256 r, uint256 s, uint8 v) signature)[] deposits)']) const dec = decodeFunctionData({ abi, data: tx.data! }) check('it is batchedDepositWithPermit for the requesting user', dec.functionName === 'batchedDepositWithPermit' && (dec.args[0] as any[])[0].user.toLowerCase() === user.address.toLowerCase()) check('the attacker address appears nowhere in it', !raw.toLowerCase().includes(strip(ATTACKER))) } } // ── 3. withdrawals and sends are user-signed ───────────────────────────── console.log('\n3. withdrawals and sends are signed by the user\'s wallet only') { const nonce = Date.now() const dest = '0x1111111111111111111111111111111111111111' const cases = [ { name: 'withdraw3', td: buildWithdrawTypedData(dest, '10', nonce), submit: (sig: Hex) => submitWithdraw(sig, dest, '10', nonce) }, { name: 'spotSend', td: buildSpotSendTypedData(dest, 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054', '10', nonce), submit: (sig: Hex) => submitSpotSend(sig, dest, 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054', '10', nonce) }, { name: 'sendAsset', td: buildSendAssetTypedData(dest, 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054', '10', nonce), submit: (sig: Hex) => submitSendAsset(sig, dest, 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054', '10', nonce) }, ] for (const c of cases) { check(`${c.name}: user-signed EIP-712 domain (HyperliquidSignTransaction), not the agent L1 domain`, c.td.domain.name === 'HyperliquidSignTransaction' && c.td.domain.chainId === 421614) const sig = await user.signTypedData(c.td as any) calls = [] await c.submit(sig) const body = calls[0] ? JSON.parse(calls[0].body) : null check(`${c.name}: posted with action type ${c.name}`, body?.action?.type === c.name, body?.action?.type) const signer = body ? await recoverTypedDataAddress({ ...(c.td as any), signature: sigOf(body.signature) }) : null check(`${c.name}: the posted signature recovers to the user's wallet`, signer === user.address, String(signer)) check(`${c.name}: no vault / agent delegation on the request`, body?.vaultAddress === null) } const ucd = buildUsdClassTransferTypedData('1', true, nonce) check('usdClassTransfer is user-signed too', ucd.domain.name === 'HyperliquidSignTransaction') // Source shape: the agent signer never touches a money-moving action, and no // other file can build one. const src = read('src/lib/hlSigning.ts') const chunks = src.split(/\n(?=(?:export )?(?:async )?function )/) const agentSigned = chunks.filter((c) => c.includes('signWithAgent(')) check('agent-signed functions exist (orders, cancels, leverage)', agentSigned.length >= 3) const moneyActs = /'(withdraw3|spotSend|sendAsset|usdSend|usdClassTransfer)'/ const bad = agentSigned.filter((c) => moneyActs.test(c)).map((c) => c.slice(0, 60)) check('no agent-signed function builds a withdraw/send/transfer action', bad.length === 0, JSON.stringify(bad)) const moneyFiles = [...CLIENT_FILES, ...SERVER_FILES].filter((f) => /'(withdraw3|spotSend|sendAsset|usdSend)'/.test(read(f))) check('withdraw/send actions are built only in hlSigning.ts', JSON.stringify(moneyFiles) === JSON.stringify(['src/lib/hlSigning.ts']), JSON.stringify(moneyFiles)) const serverExchange = SERVER_FILES.filter((f) => /hyperliquid\.xyz\/exchange|withdraw3|spotSend|sendAsset|usdSend/.test(read(f))) check('no server file posts to the HL exchange endpoint or names a money action', serverExchange.length === 0, JSON.stringify(serverExchange)) // The *WithKey helpers sign with a user-owned burner key held in the browser. const callers = CLIENT_FILES.filter((f) => /\b(sendAssetWithKey|usdClassTransferWithKey)\(/.test(read(f)) && f !== 'src/lib/hlSigning.ts') const withKeyArgs = callers.flatMap((f) => [...read(f).matchAll(/\b(?:sendAssetWithKey|usdClassTransferWithKey)\(\s*([A-Za-z0-9_.]+)/g)].map((x) => x[1])) check('owner-key sends are signed only with a user burner\'s own key', withKeyArgs.length > 0 && withKeyArgs.every((a) => /burner\.privateKey$|^b\.privateKey$/.test(a)), JSON.stringify(withKeyArgs)) check('builder address is not a destination default anywhere', !/destination[^\n]{0,40}BUILDER_ADDRESS/.test(src) && BUILDER_ADDRESS.length === 42) } // ── 4. withdrawals and sends, end to end (W1 XX-19) ────────────────────── console.log('\n4. withdrawals and sends go straight to the exchange, as signed') { const HL_EXCHANGE = 'https://api.hyperliquid.xyz/exchange' const nonce = Date.now() const dest = '0x2222222222222222222222222222222222222222' const TOKEN = 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054' const userSigned = [ { name: 'withdraw3', td: buildWithdrawTypedData(dest, '25', nonce), submit: (sig: Hex) => submitWithdraw(sig, dest, '25', nonce) }, { name: 'spotSend', td: buildSpotSendTypedData(dest, TOKEN, '25', nonce), submit: (sig: Hex) => submitSpotSend(sig, dest, TOKEN, '25', nonce) }, { name: 'sendAsset', td: buildSendAssetTypedData(dest, TOKEN, '25', nonce), submit: (sig: Hex) => submitSendAsset(sig, dest, TOKEN, '25', nonce) }, ] for (const c of userSigned) { const sig = await user.signTypedData(c.td as any) calls = [] await c.submit(sig) check(`${c.name}: exactly one request, to the exchange (no xXTrade server in the path)`, calls.length === 1 && calls[0].url === HL_EXCHANGE, calls.map((x) => x.url).join(',')) const body = calls[0] ? JSON.parse(calls[0].body) : null const signedDest = String((c.td.message as any).destination).toLowerCase() check(`${c.name}: the posted destination and amount are the signed ones`, String(body?.action?.destination).toLowerCase() === signedDest && signedDest === dest && body?.action?.amount === '25' && String((c.td.message as any).amount) === '25', JSON.stringify(body?.action)) } { const td = buildUsdClassTransferTypedData('5', true, nonce) const sig = await user.signTypedData(td as any) calls = [] await submitUsdClassTransfer(sig, '5', true, nonce) const body = calls[0] ? JSON.parse(calls[0].body) : null const signer = body ? await recoverTypedDataAddress({ ...(td as any), signature: sigOf(body.signature) }) : null check('usdClassTransfer (spot <-> perps, same account): posted once, recovers to the user', calls.length === 1 && calls[0].url === HL_EXCHANGE && signer === user.address, String(signer)) } // Burner (mirror) wallets: the user owns their keys; a burner signs its own sends. const burnerKey = generatePrivateKey() const burner = privateKeyToAccount(burnerKey) calls = [] await sendAssetWithKey(burnerKey, user.address, TOKEN, '3') { const body = calls[0] ? JSON.parse(calls[0].body) : null const signer = body ? await recoverTypedDataAddress({ ...(buildSendAssetTypedData(user.address, TOKEN, '3', body.nonce) as any), signature: sigOf(body.signature), }) : null check('burner sendAsset: one request to the exchange', calls.length === 1 && calls[0].url === HL_EXCHANGE, calls.map((x) => x.url).join(',')) check('burner sendAsset: signed by the burner\'s own key', signer === burner.address, String(signer)) check('burner sendAsset: the request never carries the burner key', !calls.some((x) => x.body.toLowerCase().includes(strip(burnerKey)))) } calls = [] await usdClassTransferWithKey(burnerKey, '3', false) { const body = calls[0] ? JSON.parse(calls[0].body) : null const signer = body ? await recoverTypedDataAddress({ ...(buildUsdClassTransferTypedData('3', false, body.nonce) as any), signature: sigOf(body.signature), }) : null check('burner usdClassTransfer: signed by the burner, key not sent', signer === burner.address && !calls.some((x) => x.body.toLowerCase().includes(strip(burnerKey))), String(signer)) } const sweeps = CLIENT_FILES.flatMap((f) => [...read(f).matchAll(/\bsendAssetWithKey\(\s*[A-Za-z0-9_.]+\s*,\s*([A-Za-z0-9_.]+)/g)].map((m) => `${f}:${m[1]}`)) check('burner sends only ever go back to the connected wallet (evmAddress)', sweeps.length > 0 && sweeps.every((x) => x.endsWith(':evmAddress')), JSON.stringify(sweeps)) // The connected-wallet flows sign with the user's own wallet provider. const ctx = read('src/context/EvmWalletContext.tsx') for (const fn of ['submitWithdraw', 'submitSpotSend', 'submitSendAsset']) { const at = ctx.search(new RegExp(`await ${fn}\\(`)) const before = at >= 0 ? ctx.slice(Math.max(0, at - 400), at) : '' check(`EvmWalletContext: ${fn} is preceded by the user's own signature (signUserAction), never the agent key`, at >= 0 && /const sig = await signUserAction\(provider, typedData\)/.test(before) && !/agentWallet|privateKey/.test(before)) } // The agent's only fund-moving action: vaultTransfer. It names a vault, no other destination. const agent = generateAgent(user.address) const HLP = '0xdfc24b077bc1425ad1dea75bcb6f8158e10df303' calls = [] await vaultTransfer(agent.privateKey, HLP, true, 10) { const body = calls[0] ? JSON.parse(calls[0].body) : null check('vaultTransfer: one request to the exchange, the agent key not in it', calls.length === 1 && calls[0].url === HL_EXCHANGE && !calls[0].body.toLowerCase().includes(strip(agent.privateKey))) check('vaultTransfer: the action carries only type / vault / direction / amount', Object.keys(body?.action ?? {}).join() === 'type,vaultAddress,isDeposit,usd' && body?.action?.vaultAddress === HLP, JSON.stringify(body?.action)) } const vaultCalls = CLIENT_FILES.flatMap((f) => [...read(f).matchAll(/\bvaultTransfer\(\s*[A-Za-z0-9_.]+\s*,\s*([A-Za-z0-9_.]+)/g)].map((m) => m[1])) check('vaultTransfer is only called with HLP or a vault from the vault list', vaultCalls.length > 0 && vaultCalls.every((a) => a === 'HLP_ADDRESS' || a === 'vault.vaultAddress'), JSON.stringify(vaultCalls)) // No server can sign for a user on any chain. const solSigners = SERVER_FILES.filter((f) => /\bKeypair\b|fromSecretKey|nacl\.sign|signTransaction\(/.test(read(f))) check('no server file holds a Solana keypair or signs a transaction for a user', solSigners.length === 0, JSON.stringify(solSigners)) } } main() .catch((e) => { failures++; console.log(` ✗ threw: ${e instanceof Error ? e.stack : e}`) }) .finally(() => { globalThis.fetch = realFetch console.log(failures ? `\n${failures} FAILED` : '\nall passed') process.exit(failures ? 1 : 0) })