From ae47824be16de6defeece7c27bc909325f6d9de8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 12 Oct 2026 17:55:55 +0200 Subject: [PATCH] feat: add funded arbitrum fixtures for wallet recovery --- README.md | 100 ++++++++++ arbitrum-fixture/.dockerignore | 1 + arbitrum-fixture/.gitignore | 2 + arbitrum-fixture/Dockerfile | 8 + arbitrum-fixture/artifact_test.py | 51 +++++ arbitrum-fixture/package-lock.json | 119 +++++++++++ arbitrum-fixture/package.json | 1 + arbitrum-fixture/provider.mjs | 285 +++++++++++++++++++++++++++ arbitrum-fixture/smoke.mjs | 72 +++++++ docker-compose.yml | 34 ++++ scripts/fetch-bitkit-core-android.py | 67 +++++++ scripts/render-arbitrum-profile.py | 26 +++ 12 files changed, 766 insertions(+) create mode 100644 arbitrum-fixture/.dockerignore create mode 100644 arbitrum-fixture/.gitignore create mode 100644 arbitrum-fixture/Dockerfile create mode 100644 arbitrum-fixture/artifact_test.py create mode 100644 arbitrum-fixture/package-lock.json create mode 100644 arbitrum-fixture/package.json create mode 100644 arbitrum-fixture/provider.mjs create mode 100644 arbitrum-fixture/smoke.mjs create mode 100755 scripts/fetch-bitkit-core-android.py create mode 100755 scripts/render-arbitrum-profile.py diff --git a/README.md b/README.md index 0ad76fc..539e976 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A complete Docker-based development environment for Bitcoin and Lightning Networ - **VSS Server**: Versioned Storage Server for app and ldk-node state backups - **Homegate**: Pubky Homeserver signup gatekeeper with local admin API mock - **Pubky marketplace fixture** (opt-in `marketplace` profile): Pubky testnet, Paykit Server and a purchase driver for the marketplace wallet journey +- **Arbitrum USDT fixture** (opt-in `arbitrum` profile): a local fork, ERC-20 funding and a controlled EIP-7702 bundler - **Payment Request fixture** (opt-in `payment-requests` profile): rc65 issuer and controlled peer on the marketplace Pubky testnet ## Quick Start @@ -109,6 +110,104 @@ curl -fsS http://localhost:23010/fixture/invoices | jq Healthy invoices use the requested amount in millisatoshis and bind the exact metadata with a SHA-256 description hash. They are signed, freshly generated `lnbcrt` invoices with a one-hour expiry and payment secret. This fixture supports invoice fetching, decoding and callback retry journeys; it has no Lightning node or channels and cannot settle payments. Use the regular LNURL server with LND for actual payments. Its controls are unauthenticated and intended only for disposable local test environments. +### Arbitrum USDT fixture + +The opt-in `arbitrum` profile provides a funded local Arbitrum One fork and an +ERC-4337/EIP-7702 provider. It retains the addresses of USDT0, EntryPoint 0.8, +Simple7702Account and the ERC-20 paymaster used by Core. The upstream RPC is +read-only; funding, signer registration and all transactions execute on Anvil. +The default upstream is `https://arb1.arbitrum.io/rpc`; a credential-free archive +RPC can be selected with `ARBITRUM_FORK_URL` if the public endpoint is unavailable. + +```bash +docker compose --profile arbitrum up -d --build --wait --wait-timeout 240 arbitrum-provider +curl -fsS http://127.0.0.1:23546/health +# Prove the fixture itself: funded account, two signed transfers, lost response, +# real receipts and a submission journal. This uses disposable test accounts. +docker compose --profile arbitrum exec -T arbitrum-provider node smoke.mjs +``` + +The default public test mnemonic is `test test test test test test test test test test test junk`. +Its first account starts with 1,000 USDT. Fund any app's displayed Arbitrum address +without changing its mnemonic: + +```bash +curl -fsS http://127.0.0.1:23546/v1/usdt/chain-rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"test_fundWallet","params":[""]}' +curl -fsS http://127.0.0.1:23546/fixture/submissions +# Execute the next signed operation, then close the connection before replying. +curl -fsS http://127.0.0.1:23546/fixture -H 'Content-Type: application/json' \ + -d '{"dropNextSubmissionResponse":true}' +``` + +`test_fundWallet` sets exactly 1,000 USDT and returns `true`; it does not reset an +account's nonce or delegation. The submission journal records receipt time, +operation hash, transaction hash and mined block. Requests rejected before +broadcast remain marked `received`; only `submitted` or `mined` records prove a +broadcast. The one-shot lost-response control resets to `false` when consumed +or when the provider restarts. Wallet restarts and provider restarts preserve +chain evidence and the journal. Keep Anvil running throughout a recovery run: +Anvil restart does not preserve historical receipts. The provider checks the +chain instance and refuses to serve a replacement chain with the old journal. +For a new fixture, recreate both services and only this project's +`arbitrum_fixture_data` volume; do not tear down unrelated seat services. + +Both `/v1/usdt/chain-rpc` and `/v1/usdt/rpc` on port `23546` answer JSON-RPC. +`eth_getLogs` exposes only locally mined history after the fork block. Gas and +token pricing are fixed fixture estimates; this is not a production bundler, +provider-pricing test or a bridge/destination fixture. Contract execution and +receipt logs are real. The provider adapts Core's `tests/usdt-fork/provider.mjs` +from commit `98bf9c6`, with arbitrary-wallet funding, health checks, a durable +journal, restart guards and a lost-response control. It requires Node 22 and +pinned ethers 6.17.0; Anvil is pinned to Foundry 1.3.1. + +For a QA seat that was already provisioned before this profile existed, render +only these services and start them in the seat's existing project. This also +works when its current generated Compose file lacks the new profile: + +```bash +# On the fixture box, check out the fixture commit in its bitkit-docker copy, +# then run from that checkout. Set the project's name and port offset from +# the seat's environment; offset is its published bitcoind RPC port minus 43782. +python3 scripts/render-arbitrum-profile.py --port-offset \ + --out /arbitrum.compose.json +docker compose -p -f /arbitrum.compose.json \ + --profile arbitrum up -d --build --wait --wait-timeout 240 arbitrum-provider +curl -fsS http://127.0.0.1:<23546+offset>/health +``` + +The rendered file keeps the seat's project/network and shifts the two base +ports by its block. It includes only these services, so starting it leaves the +project's existing services running. Do not pass `--remove-orphans`. The box +checkout returns to `main` after the fixture change is merged. Relay the seat's +provider port to its own device host, then use that seat's ADB server and serial: +`adb -P -s reverse tcp:23546 tcp:`. +Core accepts fixture HTTP URLs on device loopback, not `10.0.2.2`. + +Android builds must select the reviewed Core artifact, including matching +bindings and every native ABI, instead of a published older version: + +```bash +# On the coordinator or build host, using the existing gh login: +python3 scripts/fetch-bitkit-core-android.py --commit \ + --out +# In the test run's Android checkout, use the AAR path from manifest.json: +./gradlew :app:assembleDevDebug -PbitkitCoreAar= \ + -PUSDT_RPC_URL=http://127.0.0.1:23546/v1/usdt/chain-rpc \ + -PUSDT_BUNDLER_URL=http://127.0.0.1:23546/v1/usdt/rpc \ + -PUSDT_BRIDGES_URL= -PUSDT_DEPOSITS_URL= -PUSDT_BRIDGE_NETWORKS= +``` + +The helper requires a successful `Android Bindgen` run at that exact SHA and +its unexpired `bitkit-core-android-bindgen` artifact. It checks four ELF native +libraries and `UsdtWallet.sendWithDeadline`, writes the AAR checksum and source +run to `manifest.json`, and fails explicitly while CI is building. It never +falls back to a release or edits an app checkout. A local AAR changes the Core +dependency; the test run must separately use an app integration that calls the +intended Core API. Artifact checks run with +`python3 -m unittest discover -s arbitrum-fixture -p '*_test.py'`. + ### VSS Server - **Port**: 23050 (`127.0.0.1:23050` from the `vss` profile) @@ -152,6 +251,7 @@ Every host port is 1024 or above. The ports Bitkit, the Pubky SDK or Bitkit's UI | LDK backup server | 23003 | 3003 | | LNURL server | 23000 | 3000 | | LNURL-pay fixture (`lnurl-pay`) | 23010 | 3010 | +| Arbitrum chain, provider (`arbitrum`) | 23545, 23546 | 8545, 8546 | | PostgreSQL | 23432 | 5432 | | LNURL-auth server | 23005 | 5005 | | VSS server | 23050 | 5050 | diff --git a/arbitrum-fixture/.dockerignore b/arbitrum-fixture/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/arbitrum-fixture/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/arbitrum-fixture/.gitignore b/arbitrum-fixture/.gitignore new file mode 100644 index 0000000..4b7a81d --- /dev/null +++ b/arbitrum-fixture/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +__pycache__/ diff --git a/arbitrum-fixture/Dockerfile b/arbitrum-fixture/Dockerfile new file mode 100644 index 0000000..4344946 --- /dev/null +++ b/arbitrum-fixture/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22.11.0-alpine3.20 +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY *.mjs ./ +RUN mkdir /data && chown node:node /data +USER node +CMD ["node", "provider.mjs"] diff --git a/arbitrum-fixture/artifact_test.py b/arbitrum-fixture/artifact_test.py new file mode 100644 index 0000000..f527b52 --- /dev/null +++ b/arbitrum-fixture/artifact_test.py @@ -0,0 +1,51 @@ +import importlib.util +import io +from pathlib import Path +import tempfile +import struct +import unittest +import zipfile + +spec = importlib.util.spec_from_file_location('artifact', Path(__file__).parent.parent / 'scripts/fetch-bitkit-core-android.py') +artifact = importlib.util.module_from_spec(spec) +spec.loader.exec_module(artifact) + +class ArtifactValidation(unittest.TestCase): + def aar(self, path, deadline=True, abis=('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64')): + jar = io.BytesIO() + with zipfile.ZipFile(jar, 'w') as classes: + classes.writestr('com/synonym/bitkitcore/UsdtWallet.class', b'CAFEBABE sendWithDeadline' if deadline else b'CAFEBABE send') + with zipfile.ZipFile(path, 'w') as aar: + aar.writestr('classes.jar', jar.getvalue()) + for abi in abis: + native = bytearray(64); native[:4] = b'\x7fELF'; native[5] = 1 + struct.pack_into('= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/arbitrum-fixture/package.json b/arbitrum-fixture/package.json new file mode 100644 index 0000000..13254b7 --- /dev/null +++ b/arbitrum-fixture/package.json @@ -0,0 +1 @@ +{"name":"bitkit-arbitrum-fixture","private":true,"type":"module","scripts":{"smoke":"node smoke.mjs"},"dependencies":{"ethers":"6.17.0"}} diff --git a/arbitrum-fixture/provider.mjs b/arbitrum-fixture/provider.mjs new file mode 100644 index 0000000..4164544 --- /dev/null +++ b/arbitrum-fixture/provider.mjs @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs'; +import { + AbiCoder, + Contract, + HDNodeWallet, + JsonRpcProvider, + concat, + getBytes, + keccak256, + toBeHex, + zeroPadValue, + isAddress, +} from 'ethers'; + +// Adapted from synonymdev/bitkit-core tests/usdt-fork/provider.mjs (98bf9c6). +// A local service fixture using the deployed contracts with a test paymaster signer. +// It does not model Pimlico's pricing, gas estimation, or service acceptance policy. +const rpc = new JsonRpcProvider(process.env.ANVIL_URL ?? 'http://127.0.0.1:8545', 42161, { + staticNetwork: true, + cacheTimeout: -1, +}); +assert.match(await rpc.send('web3_clientVersion', []), /anvil/i); +assert.equal(await rpc.send('eth_chainId', []), '0xa4b1'); +const signer = HDNodeWallet.fromPhrase( + 'test test test test test test test test test test test junk', + undefined, + "m/44'/60'/0'/0/1", +); +const bundler = await rpc.getSigner(signer.address); +const account = HDNodeWallet.fromPhrase('test test test test test test test test test test test junk').address; +const delegate = '0xe6Cae83BdE06E4c305530e199D7217f42808555B'; +const tokenAddress = '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9'; +const pmAddress = '0x888888888888Ec68A58AB8094Cc1AD20Ba3D2402'; +const entryAddress = '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108'; +const packedType = + '(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,bytes signature)'; +const token = new Contract( + tokenAddress, + ['function balanceOf(address) view returns (uint256)'], + rpc, +); +const pm = new Contract( + pmAddress, + [ + `function getHash(uint8,${packedType}) view returns (bytes32)`, + 'function signers(address) view returns (bool)', + ], + rpc, +); +const entry = new Contract( + entryAddress, + [ + `function handleOps(${packedType}[],address)`, + `function getUserOpHash(${packedType}) view returns (bytes32)`, + 'function depositTo(address) payable', + ], + bundler, +); +const coder = AbiCoder.defaultAbiCoder(); + +async function setMapping(contract, account, value, check) { + for (let slot = 0; slot < 100; slot++) { + const key = keccak256(coder.encode(['address', 'uint256'], [account, slot])); + const previous = await rpc.send('eth_getStorageAt', [contract, key, 'latest']); + await rpc.send('anvil_setStorageAt', [contract, key, toBeHex(value, 32)]); + if (await check()) return slot; + await rpc.send('anvil_setStorageAt', [contract, key, previous]); + } + throw Error('Could not locate fixture mapping'); +} +const currentBlock = Number(BigInt(await rpc.send('eth_blockNumber', []))); +for (const address of [delegate, tokenAddress, pmAddress, entryAddress]) + assert.notEqual(await rpc.send('eth_getCode', [address, 'latest']), '0x', `Missing deployed contract ${address}`); +const statePath = process.env.STATE_PATH ?? '/data/submissions.json'; +const saved = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : null; +const metadata = await rpc.send('anvil_metadata', []); +const instanceId = metadata.instanceId; +assert(!saved || saved.instanceId === instanceId, 'Chain restarted; recreate the fixture volume and both services'); +async function activeChain() { + assert.equal((await rpc.send('anvil_metadata', [])).instanceId, instanceId, 'Chain restarted; recreate the fixture volume and both services'); +} +const forkBlock = saved?.forkBlock ?? currentBlock; +assert(currentBlock >= forkBlock, 'Chain state was reset; reset both fixture volumes together'); +const journal = saved?.submissions ?? []; +let balanceSlot = saved?.balanceSlot; +function saveJournal() { + writeFileSync(`${statePath}.tmp`, JSON.stringify({ instanceId, forkBlock, balanceSlot, submissions: journal })); + renameSync(`${statePath}.tmp`, statePath); +} +let controls = { dropNextSubmissionResponse: false }; +let serial = Promise.resolve(); +if (balanceSlot === undefined) balanceSlot = await setMapping( + tokenAddress, + '0x000000000000000000000000000000000000dEaD', + 1_000_000_000n, + async () => (await token.balanceOf('0x000000000000000000000000000000000000dEaD')) === 1_000_000_000n, +); +if (!(await pm.signers(signer.address))) + await setMapping(pmAddress, signer.address, 1n, async () => await pm.signers(signer.address)); +if (!saved) { + const key = keccak256(coder.encode(['address', 'uint256'], [account, balanceSlot])); + await rpc.send('anvil_setStorageAt', [tokenAddress, key, toBeHex(1_000_000_000n, 32)]); + await (await entry.depositTo(pmAddress, { value: 10n ** 18n })).wait(); +} +saveJournal(); + +const gas = { + callGasLimit: toBeHex(1_500_000), + verificationGasLimit: toBeHex(800_000), + preVerificationGas: toBeHex(100_000), + paymasterVerificationGasLimit: toBeHex(100_000), + paymasterPostOpGasLimit: toBeHex(100_000), +}; +const rate = 3_000_000_000n; +function pack(op) { + return { + sender: op.sender, + nonce: op.nonce, + initCode: concat(['0x7702', toBeHex(0, 18)]), + callData: op.callData, + accountGasLimits: concat([toBeHex(op.verificationGasLimit, 16), toBeHex(op.callGasLimit, 16)]), + preVerificationGas: op.preVerificationGas, + gasFees: concat([toBeHex(op.maxPriorityFeePerGas, 16), toBeHex(op.maxFeePerGas, 16)]), + paymasterAndData: concat([ + op.paymaster, + toBeHex(op.paymasterVerificationGasLimit, 16), + toBeHex(op.paymasterPostOpGasLimit, 16), + op.paymasterData, + ]), + signature: op.signature, + }; +} +async function dispatch(method, params) { + if (method === 'eth_estimateUserOperationGas' || method === 'eth_sendUserOperation') { + const op = params[0]; + assert.equal(op.factory, '0x7702'); + assert.ok(op.eip7702Auth, 'The 7702 factory marker requires authorization'); + assert.equal( + BigInt(op.eip7702Auth.nonce), + BigInt(await rpc.send('eth_getTransactionCount', [op.sender, 'latest'])), + 'Authorization must use the current account nonce', + ); + } + if (method === 'test_fundWallet') { + assert(isAddress(params[0]), 'Expected an EVM wallet address'); + const key = keccak256(coder.encode(['address', 'uint256'], [params[0], balanceSlot])); + await rpc.send('anvil_setStorageAt', [tokenAddress, key, toBeHex(1_000_000_000n, 32)]); + return true; + } + if (method === 'pimlico_getTokenQuotes') + return { + quotes: [ + { + token: tokenAddress, + paymaster: pmAddress, + postOpGas: toBeHex(50000), + exchangeRate: toBeHex(rate), + }, + ], + }; + if (method === 'pimlico_getUserOperationGasPrice') + return { + slow: { maxFeePerGas: toBeHex(90_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, + fast: { maxFeePerGas: toBeHex(100_000_000), maxPriorityFeePerGas: toBeHex(1_000_000) }, + }; + if (method === 'eth_estimateUserOperationGas') return gas; + if (method === 'pm_getPaymasterData' || method === 'pm_getPaymasterStubData') { + const stub = method === 'pm_getPaymasterStubData'; + const limits = stub ? { paymasterPostOpGasLimit: gas.paymasterPostOpGasLimit } : {}; + const op = { ...params[0], paymaster: pmAddress, ...limits }; + const unsigned = concat([ + '0x0300', + toBeHex(BigInt((await rpc.send('eth_getBlockByNumber', ['latest', false])).timestamp) + 600n, 6), + toBeHex(0, 6), + tokenAddress, + toBeHex(50000, 16), + toBeHex(rate, 32), + toBeHex(100000, 16), + signer.address, + ]); + op.paymasterData = concat([unsigned, zeroPadValue('0x01', 65)]); + const signature = await signer.signMessage(getBytes(await pm.getHash(1, pack(op)))); + return { paymaster: pmAddress, paymasterData: concat([unsigned, signature]), ...limits }; + } + if (method === 'eth_sendUserOperation') { + const op = pack(params[0]); + const hash = await rpc.send('eth_call', [ + { to: entryAddress, data: entry.interface.encodeFunctionData('getUserOpHash', [op]) }, + 'latest', + { [op.sender]: { code: concat(['0xef0100', delegate]) } }, + ]); + const auth = params[0].eip7702Auth; + const options = { + gasLimit: 6_000_000, + type: 4, + authorizationList: [ + { + address: auth.address, + chainId: BigInt(auth.chainId), + nonce: BigInt(auth.nonce), + signature: { r: auth.r, s: auth.s, yParity: Number(BigInt(auth.yParity)) }, + }, + ], + }; + const record = { operation: hash, sender: op.sender, receivedAt: new Date().toISOString(), status: 'received' }; + journal.push(record); saveJournal(); + const tx = await entry.handleOps([op], signer.address, options); + record.transaction = tx.hash; record.status = 'submitted'; saveJournal(); + const receipt = await tx.wait(); + record.status = 'mined'; record.blockNumber = receipt.blockNumber; saveJournal(); + await rpc.send('anvil_mine', [3]); + console.log( + JSON.stringify({ + operation: hash, + transaction: receipt.hash, + gasUsed: receipt.gasUsed.toString(), + }), + ); + return hash; + } + if (method === 'bitkit_getBridgeMessages') return { data: [] }; + if (method === 'eth_getLogs') { + const filter = { ...params[0] }; + if (!filter.blockHash) { + const lower = filter.fromBlock && !['earliest', 'latest', 'pending'].includes(filter.fromBlock) ? Number(BigInt(filter.fromBlock)) : 0; + const upper = filter.toBlock && !['latest', 'pending'].includes(filter.toBlock) ? Number(BigInt(filter.toBlock)) : Infinity; + if (upper <= forkBlock) return []; + filter.fromBlock = toBeHex(Math.max(lower, forkBlock + 1)); + } + return rpc.send(method, [filter]); + } + return rpc.send(method, params); +} +const server = createServer(async (request, response) => { + try { + await activeChain(); + if (request.method === 'GET' && request.url === '/health') { + assert.equal(await rpc.send('eth_chainId', []), '0xa4b1'); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ready: true, chainId: 42161, forkBlock, account, token: tokenAddress, paymaster: pmAddress, entryPoint: entryAddress, controls, submissions: journal.length })); + return; + } + if (request.method === 'GET' && request.url === '/fixture/submissions') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(journal)); return; + } + let body = ''; + for await (const chunk of request) { + body += chunk; + if (body.length > 2 * 1024 * 1024) throw Error('Request too large'); + } + const call = JSON.parse(body); + if (request.method === 'POST' && request.url === '/fixture') { + assert.equal(typeof call.dropNextSubmissionResponse, 'boolean'); + controls = { dropNextSubmissionResponse: call.dropNextSubmissionResponse }; + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(controls)); return; + } + let result; + try { + if (call.method === 'eth_sendUserOperation') { + const run = serial.then(() => dispatch(call.method, call.params ?? [])); + serial = run.catch(() => {}); + result = { jsonrpc: '2.0', id: call.id, result: await run }; + if (controls.dropNextSubmissionResponse) { + controls.dropNextSubmissionResponse = false; + response.destroy(); return; + } + } else result = { jsonrpc: '2.0', id: call.id, result: await dispatch(call.method, call.params ?? []) }; + } catch (error) { + result = { jsonrpc: '2.0', id: call.id, error: { code: -32000, message: error.shortMessage ?? error.message } }; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(result)); + } catch (error) { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: error.message })); + } +}); +server.listen(Number(process.env.PORT ?? 8546), '0.0.0.0', () => + console.log('Arbitrum fork fixture ready; transactions execute only on Anvil.'), +); +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.close(() => { rpc.destroy(); process.exit(0); })); diff --git a/arbitrum-fixture/smoke.mjs b/arbitrum-fixture/smoke.mjs new file mode 100644 index 0000000..86adb29 --- /dev/null +++ b/arbitrum-fixture/smoke.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { Contract, HDNodeWallet, Interface, JsonRpcProvider, concat, toBeHex, zeroPadValue } from 'ethers'; +const origin = process.env.FIXTURE_URL ?? 'http://127.0.0.1:8546'; +const rpc = new JsonRpcProvider(origin, 42161, { staticNetwork: true, cacheTimeout: -1 }); +const chain = new JsonRpcProvider(process.env.ANVIL_URL ?? 'http://127.0.0.1:8545', 42161, { staticNetwork: true, cacheTimeout: -1 }); +const health = await (await fetch(`${origin}/health`)).json(); +assert.equal(health.ready, true); +assert.equal(health.chainId, 42161); +const wallet = HDNodeWallet.fromPhrase('test test test test test test test test test test test junk', undefined, "m/44'/60'/0'/0/5"); +const recipient = HDNodeWallet.fromPhrase('test test test test test test test test test test test junk', undefined, "m/44'/60'/0'/0/6").address; +const delegate = '0xe6Cae83BdE06E4c305530e199D7217f42808555B'; +const token = new Contract(health.token, ['function balanceOf(address) view returns (uint256)', 'function approve(address,uint256)', 'function transfer(address,uint256)'], rpc); +const packedType = '(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,bytes signature)'; +const entry = new Contract(health.entryPoint, [`function getUserOpHash(${packedType}) view returns (bytes32)`, 'function getNonce(address,uint192) view returns (uint256)'], chain); +const account = new Interface(['function executeBatch((address target,uint256 value,bytes data)[] calls)']); +await chain.send('anvil_setBalance', [wallet.address, '0x0']); +assert.equal(await rpc.send('test_fundWallet', [wallet.address]), true); +assert.equal(await token.balanceOf(wallet.address), 1_000_000_000n); +assert.equal(await rpc.send('eth_getBalance', [wallet.address, 'latest']), '0x0'); +const quotes = await rpc.send('pimlico_getTokenQuotes', [{ tokens: [health.token] }]); +assert.equal(quotes.quotes[0].paymaster, health.paymaster); +function pack(op) { + return { + sender: op.sender, nonce: op.nonce, initCode: concat(['0x7702', toBeHex(0, 18)]), callData: op.callData, + accountGasLimits: concat([toBeHex(op.verificationGasLimit, 16), toBeHex(op.callGasLimit, 16)]), + preVerificationGas: op.preVerificationGas, + gasFees: concat([toBeHex(op.maxPriorityFeePerGas, 16), toBeHex(op.maxFeePerGas, 16)]), + paymasterAndData: concat([op.paymaster, toBeHex(op.paymasterVerificationGasLimit, 16), toBeHex(op.paymasterPostOpGasLimit, 16), op.paymasterData]), signature: op.signature, + }; +} +async function operation() { + const nonce = await chain.send('eth_getTransactionCount', [wallet.address, 'latest']); + const auth = await wallet.authorize({ address: delegate, chainId: 42161, nonce: BigInt(nonce) }); + const op = { + sender: wallet.address, nonce: toBeHex(await entry.getNonce(wallet.address, 0)), factory: '0x7702', factoryData: '0x', + callData: account.encodeFunctionData('executeBatch', [[ + { target: health.token, value: 0, data: token.interface.encodeFunctionData('approve', [health.paymaster, 100_000_000]) }, + { target: health.token, value: 0, data: token.interface.encodeFunctionData('transfer', [recipient, 1_000_000]) }, + ]]), + maxFeePerGas: toBeHex(100_000_000), maxPriorityFeePerGas: toBeHex(1_000_000), + signature: zeroPadValue('0x01', 65), + eip7702Auth: { address: auth.address, chainId: toBeHex(auth.chainId), nonce: toBeHex(auth.nonce), r: auth.signature.r, s: auth.signature.s, yParity: toBeHex(auth.signature.yParity) }, + }; + Object.assign(op, await rpc.send('eth_estimateUserOperationGas', [op, health.entryPoint])); + Object.assign(op, await rpc.send('pm_getPaymasterData', [op, health.entryPoint, '0xa4b1', {}])); + const hash = await chain.send('eth_call', [ + { to: health.entryPoint, data: entry.interface.encodeFunctionData('getUserOpHash', [pack(op)]) }, 'latest', + { [wallet.address]: { code: concat(['0xef0100', delegate]) } }, + ]); + op.signature = wallet.signingKey.sign(hash).serialized; + return { op, hash }; +} +try { + const recipientBefore = await token.balanceOf(recipient); + const first = await operation(); + assert.equal(await rpc.send('eth_sendUserOperation', [first.op, health.entryPoint]), first.hash); + assert.equal(await token.balanceOf(recipient), recipientBefore + 1_000_000n); + let submissions = await (await fetch(`${origin}/fixture/submissions`)).json(); + const record = submissions.find(r => r.operation === first.hash); + assert.equal(record.status, 'mined'); + const receipt = await rpc.send('eth_getTransactionReceipt', [record.transaction]); + assert.equal(receipt.status, '0x1'); + assert(receipt.logs.length > 0); + assert.deepEqual(await rpc.send('eth_getLogs', [{ fromBlock: '0x0', toBlock: toBeHex(health.forkBlock), address: health.token }]), []); + await fetch(`${origin}/fixture`, { method: 'POST', body: JSON.stringify({ dropNextSubmissionResponse: true }) }); + const second = await operation(); + await assert.rejects(fetch(origin, { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_sendUserOperation', params: [second.op, health.entryPoint] }) })); + submissions = await (await fetch(`${origin}/fixture/submissions`)).json(); + assert.equal(submissions.filter(r => r.operation === second.hash && r.status === 'mined').length, 1); + assert.equal(await token.balanceOf(recipient), recipientBefore + 2_000_000n); + console.log(JSON.stringify({ ready: true, fundedUSDT: '1000', zeroAccountETH: true, bundledTransfers: 2, lostResponseMinedOnce: true, receiptsAvailable: true })); +} finally { rpc.destroy(); chain.destroy(); } diff --git a/docker-compose.yml b/docker-compose.yml index 54586ec..0597749 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,39 @@ x-trezor-user-env-image: &trezor_user_env_image ghcr.io/trezor/trezor-user-env@sha256:15871ebb234bf7c0197cfcd31ddc2edbe90173cd6386fc0283fafe05bf888c06 services: + arbitrum-chain: + profiles: [arbitrum] + image: ghcr.io/foundry-rs/foundry:v1.3.1 + entrypoint: [anvil] + command: [--host, 0.0.0.0, --port, '8545', --chain-id, '42161', --hardfork, prague, --fork-url, '${ARBITRUM_FORK_URL:-https://arb1.arbitrum.io/rpc}', --gas-limit, '30000000'] + ports: + - '127.0.0.1:23545:8545' + healthcheck: + test: [CMD, cast, chain-id, --rpc-url, 'http://127.0.0.1:8545'] + interval: 5s + timeout: 10s + retries: 30 + + arbitrum-provider: + profiles: [arbitrum] + image: bitkit-docker/arbitrum-provider:fixture-v1 + build: ./arbitrum-fixture + environment: + ANVIL_URL: http://arbitrum-chain:8545 + ports: + - '127.0.0.1:23546:8546' + volumes: + - arbitrum_fixture_data:/data + depends_on: + arbitrum-chain: + condition: service_healthy + healthcheck: + test: [CMD, node, -e, "fetch('http://127.0.0.1:8546/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 5s + timeout: 15s + retries: 30 + start_period: 120s + bitcoind: container_name: bitcoin image: btcpayserver/bitcoin:26.0 @@ -865,6 +898,7 @@ services: - "127.0.0.1:23141:23141" volumes: + arbitrum_fixture_data: trezor_emulator_state: trezor_emulator_logs: bitcoin_home: diff --git a/scripts/fetch-bitkit-core-android.py b/scripts/fetch-bitkit-core-android.py new file mode 100755 index 0000000..3a2dc97 --- /dev/null +++ b/scripts/fetch-bitkit-core-android.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Fetch an exact-source CI AAR without building or changing an app checkout.""" +import argparse +import hashlib +import io +import json +from pathlib import Path +import re +import subprocess +import struct +import tempfile +import zipfile + +REPO = 'synonymdev/bitkit-core' +ARTIFACT = 'bitkit-core-android-bindgen' + +def gh(*args): + return subprocess.check_output(['gh', *args], text=True) + +def validate_aar(path): + with zipfile.ZipFile(path) as aar: + for abi, machine in {'arm64-v8a': 183, 'armeabi-v7a': 40, 'x86': 3, 'x86_64': 62}.items(): + native = aar.read(f'jni/{abi}/libbitkitcore.so') + if len(native) < 64 or not native.startswith(b'\x7fELF') or native[5] != 1 or struct.unpack_from('