diff --git a/contracts/script/test/7683/concurrentintents/.gitignore b/contracts/script/test/7683/concurrentintents/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/contracts/script/test/7683/concurrentintents/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/contracts/script/test/7683/concurrentintents/README.md b/contracts/script/test/7683/concurrentintents/README.md new file mode 100644 index 00000000..7db7cc50 --- /dev/null +++ b/contracts/script/test/7683/concurrentintents/README.md @@ -0,0 +1,13 @@ +# concurrentintents + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` diff --git a/contracts/script/test/7683/concurrentintents/bun.lock b/contracts/script/test/7683/concurrentintents/bun.lock new file mode 100644 index 00000000..93701fe7 --- /dev/null +++ b/contracts/script/test/7683/concurrentintents/bun.lock @@ -0,0 +1,55 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "concurrentintents", + "dependencies": { + "viem": "^2.44.2", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="], + + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "ox": ["ox@0.11.3", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-1bWYGk/xZel3xro3l8WGg6eq4YEKlaqvyMtVhfMFpbJzK2F6rj4EDRtqDCWVEJMkzcmEi9uW2QxsqELokOlarw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "viem": ["viem@2.44.2", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.11.3", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-nHY872t/T3flLpVsnvQT/89bwbrJwxaL917FDv7Oxy4E5FWIFkokRQOKXG3P+hgl30QYVZxi9o2SUHLnebycxw=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + } +} diff --git a/contracts/script/test/7683/concurrentintents/index.ts b/contracts/script/test/7683/concurrentintents/index.ts new file mode 100644 index 00000000..a295ec49 --- /dev/null +++ b/contracts/script/test/7683/concurrentintents/index.ts @@ -0,0 +1,342 @@ +import "dotenv/config"; +import crypto from "node:crypto"; +import { + createPublicClient, + createWalletClient, + webSocket, + parseGwei, + encodeAbiParameters, + encodeFunctionData, + padHex, + type Abi, + type Hex, + type Address, type Chain, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { arbitrum, base } from "viem/chains"; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env var: ${name}`); + return v; +} + +function asHexPrivateKey(v: string): Hex { + return (v.startsWith("0x") ? v : `0x${v}`) as Hex; +} + +function asAddress(v: string): Address { + if (!v.startsWith("0x") || v.length !== 42) throw new Error(`Invalid address: ${v}`); + return v as Address; +} + +function addressToBytes32(a: Address): Hex { + return padHex(a, { size: 32 }); +} + +const TX_COUNT = Number(process.env.TX_COUNT ?? "100"); +const TX_CONCURRENCY = Number(process.env.TX_CONCURRENCY ?? "25"); +const PRIORITY_FEE_GWEI = process.env.PRIORITY_FEE_GWEI ?? "0.05"; + +const AMOUNT_IN = BigInt(process.env.AMOUNT_IN ?? "100"); +const MIN_OUT_BPS = BigInt(process.env.MIN_OUT_BPS ?? "9000"); // 9000 = 90% +const FILL_DEADLINE_SECONDS = BigInt(process.env.FILL_DEADLINE_SECONDS ?? "300"); + +const ERC20_ABI = [ + { + type: "function", + name: "approve", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +] as const satisfies Abi; + +// Minimal ABI for T1ERC7683.open(OnchainCrossChainOrder) +const T1ERC7683_ABI = [ + { + type: "function", + name: "open", + stateMutability: "payable", + inputs: [ + { + name: "_order", + type: "tuple", + components: [ + { name: "fillDeadline", type: "uint32" }, + { name: "orderDataType", type: "bytes32" }, + { name: "orderData", type: "bytes" }, + ], + }, + ], + outputs: [], + }, +] as const satisfies Abi; + +// bytes32 orderDataType = keccak256("OrderData(...same as OrderEncoder...)") +// We hardcode the value by computing it at runtime from the exact type string. +import { keccak256, toHex } from "viem"; +const ORDER_DATA_TYPE_STRING = + "OrderData(" + + "bytes32 sender," + + "bytes32 recipient," + + "bytes32 inputToken," + + "bytes32 outputToken," + + "uint256 amountIn," + + "uint256 minAmountOut," + + "uint256 senderNonce," + + "uint32 originDomain," + + "uint32 destinationDomain," + + "bytes32 destinationSettler," + + "uint32 fillDeadline," + + "bool closedAuction," + + "bytes data)"; + +const ORDER_DATA_TYPEHASH = keccak256(toHex(ORDER_DATA_TYPE_STRING)) as Hex; + +type ChainCfg = { + name: string; + chain: Chain; + wsUrl: string; + privateKey: Hex; + + t1Erc7683: Address; + + // origin-side token address + inputToken: Address; + + // destination-side token address (encoded into orderData) + outputToken: Address; + + destinationSettler: Address; + + originDomain: number; // uint32 + destinationDomain: number; // uint32 +}; + +function randomUint256(): bigint { + return BigInt(`0x${crypto.randomBytes(32).toString("hex")}`); +} + +async function approveIfNeeded(cfg: ChainCfg, walletClient: any, publicClient: any) { + // For speed/simplicity, we just send approve(max). Many tokens allow repeated approve(max). + const maxUint256 = (1n << 256n) - 1n; + + const data = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [cfg.t1Erc7683, maxUint256], + }); + + const suggested = await publicClient.estimateFeesPerGas(); + const maxPriorityFeePerGas = parseGwei(PRIORITY_FEE_GWEI); + const maxFeePerGas = + suggested.maxFeePerGas && suggested.maxFeePerGas > maxPriorityFeePerGas + ? suggested.maxFeePerGas + : maxPriorityFeePerGas; + + const request = await publicClient.prepareTransactionRequest({ + account: privateKeyToAccount(cfg.privateKey), + to: cfg.inputToken, + data, + value: 0n, + maxFeePerGas, + maxPriorityFeePerGas, + }); + + const signed = await walletClient.signTransaction(request); + const hash = await publicClient.sendRawTransaction({ serializedTransaction: signed }); + + console.log(`[${cfg.name}] approve tx: ${hash}`); + await publicClient.waitForTransactionReceipt({ hash }); +} + +async function runChain(cfg: ChainCfg) { + const account = privateKeyToAccount(cfg.privateKey); + const owner = account.address; + + const transport = webSocket(cfg.wsUrl, { + reconnect: { attempts: Infinity, delay: 500 }, + keepAlive: { interval: 15_000 }, + }); + + const publicClient = createPublicClient({ chain: cfg.chain, transport }); + const walletClient = createWalletClient({ chain: cfg.chain, transport, account }); + + const [baseNonce, now] = await Promise.all([ + publicClient.getTransactionCount({ address: owner, blockTag: "pending" }), + publicClient.getBlock(), // includes timestamp + ]); + + const suggested = await publicClient.estimateFeesPerGas(); + const maxPriorityFeePerGas = parseGwei(PRIORITY_FEE_GWEI); + const maxFeePerGas = + suggested.maxFeePerGas && suggested.maxFeePerGas > maxPriorityFeePerGas + ? suggested.maxFeePerGas + : maxPriorityFeePerGas; + + console.log( + `[${cfg.name}] from=${owner} startNonce=${baseNonce} maxFeePerGas=${maxFeePerGas} maxPriorityFeePerGas=${maxPriorityFeePerGas}`, + ); + + // One approval before the burst (consumes 1 nonce). + await approveIfNeeded(cfg, walletClient, publicClient); + + // Re-fetch nonce after approve, to be safe. + const startNonce = await publicClient.getTransactionCount({ address: owner, blockTag: "pending" }); + + const minAmountOut = (AMOUNT_IN * MIN_OUT_BPS) / 10_000n; + const baseTimestamp = BigInt(now.timestamp); + + // Pre-sign all open() txs for speed. + const signedTxs: Hex[] = []; + for (let i = 0; i < TX_COUNT; i++) { + const senderNonce = randomUint256(); + const fillDeadline = Number(baseTimestamp + FILL_DEADLINE_SECONDS); // fits uint32 for normal timestamps + + // OrderData (solidity) as ABI tuple encoding (equivalent to abi.encode(orderData)) + const encodedOrderData = encodeAbiParameters( + [ + { + type: "tuple", + name: "orderData", + components: [ + { name: "sender", type: "bytes32" }, + { name: "recipient", type: "bytes32" }, + { name: "inputToken", type: "bytes32" }, + { name: "outputToken", type: "bytes32" }, + { name: "amountIn", type: "uint256" }, + { name: "minAmountOut", type: "uint256" }, + { name: "senderNonce", type: "uint256" }, + { name: "originDomain", type: "uint32" }, + { name: "destinationDomain", type: "uint32" }, + { name: "destinationSettler", type: "bytes32" }, + { name: "fillDeadline", type: "uint32" }, + { name: "closedAuction", type: "bool" }, + { name: "data", type: "bytes" }, + ], + }, + ], + [ + { + sender: addressToBytes32(owner), + recipient: addressToBytes32(owner), + inputToken: addressToBytes32(cfg.inputToken), + outputToken: addressToBytes32(cfg.outputToken), + amountIn: AMOUNT_IN, + minAmountOut, + senderNonce, + originDomain: cfg.originDomain, + destinationDomain: cfg.destinationDomain, + destinationSettler: addressToBytes32(cfg.destinationSettler), + fillDeadline, + closedAuction: true, + data: "0x", + }, + ], + ); + + const callData = encodeFunctionData({ + abi: T1ERC7683_ABI, + functionName: "open", + args: [ + { + fillDeadline, + orderDataType: ORDER_DATA_TYPEHASH, + orderData: encodedOrderData, + }, + ], + }); + + const nonce = startNonce + i; + + const request = await publicClient.prepareTransactionRequest({ + account, + to: cfg.t1Erc7683, + data: callData, + value: 0n, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + }); + + const signed = await walletClient.signTransaction(request); + signedTxs.push(signed); + } + + // Broadcast burst + let sent = 0; + const hashes: Hex[] = new Array(TX_COUNT); + + async function worker(workerId: number) { + for (;;) { + const idx = sent++; + if (idx >= signedTxs.length) return; + + const raw = signedTxs[idx]!; + const hash = await publicClient.sendRawTransaction({ serializedTransaction: raw }); + hashes[idx] = hash; + + console.log(`[${cfg.name}] worker=${workerId} open() ${idx + 1}/${TX_COUNT} hash=${hash}`); + } + } + + const workers = Array.from({ length: Math.min(TX_CONCURRENCY, TX_COUNT) }, (_, i) => worker(i)); + const t0 = Date.now(); + await Promise.all(workers); + const t1 = Date.now(); + + console.log(`[${cfg.name}] broadcasted ${TX_COUNT} open() tx in ${t1 - t0}ms`); + + // Optional: wait + show block spread + const receipts = await Promise.all(hashes.map((h) => publicClient.waitForTransactionReceipt({ hash: h }))); + const blocks = receipts.map((r) => r.blockNumber); + const minBlock = blocks.reduce((a, b) => (a < b ? a : b)); + const maxBlock = blocks.reduce((a, b) => (a > b ? a : b)); + console.log(`[${cfg.name}] inclusion spread: minBlock=${minBlock} maxBlock=${maxBlock} span=${maxBlock - minBlock}`); + + return { hashes, receipts }; +} + +async function main() { + const baseT1 = asAddress(requireEnv("BASE_T1_ERC7683_CONTRACT_ADDRESS")); + const arbT1 = asAddress(requireEnv("ARBITRUM_T1_ERC7683_CONTRACT_ADDRESS")); + + const baseCfg: ChainCfg = { + name: "Base (Base→Arb intents)", + chain: base, + wsUrl: requireEnv("BASE_WS"), + privateKey: asHexPrivateKey(requireEnv("BASE_SIGNER_PRIVATE_KEY")), + t1Erc7683: baseT1, + inputToken: asAddress(requireEnv("BASE_INPUT_TOKEN")), + outputToken: asAddress(requireEnv("ARB_INPUT_TOKEN")), // output token lives on destination + destinationSettler: asAddress(process.env.ARB_DESTINATION_SETTLER ?? arbT1), + originDomain: base.id, + destinationDomain: arbitrum.id, + }; + + const arbCfg: ChainCfg = { + name: "Arbitrum (Arb→Base intents)", + chain: arbitrum, + wsUrl: requireEnv("ARBITRUM_WS"), + privateKey: asHexPrivateKey(requireEnv("ARBITRUM_SIGNER_PRIVATE_KEY")), + t1Erc7683: arbT1, + inputToken: asAddress(requireEnv("ARB_INPUT_TOKEN")), + outputToken: asAddress(requireEnv("BASE_INPUT_TOKEN")), // output token lives on destination + destinationSettler: asAddress(process.env.BASE_DESTINATION_SETTLER ?? baseT1), + originDomain: arbitrum.id, + destinationDomain: base.id, + }; + + // Run both bursts in parallel so they line up in time as tightly as possible. + await Promise.all([runChain(baseCfg), runChain(arbCfg)]); + console.log("Done."); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/contracts/script/test/7683/concurrentintents/package.json b/contracts/script/test/7683/concurrentintents/package.json new file mode 100644 index 00000000..41fa6a80 --- /dev/null +++ b/contracts/script/test/7683/concurrentintents/package.json @@ -0,0 +1,15 @@ +{ + "name": "concurrentintents", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "viem": "^2.44.2" + } +} diff --git a/contracts/script/test/7683/concurrentintents/tsconfig.json b/contracts/script/test/7683/concurrentintents/tsconfig.json new file mode 100644 index 00000000..bfa0fead --- /dev/null +++ b/contracts/script/test/7683/concurrentintents/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}