diff --git a/README.md b/README.md index 6c09098..0e968fa 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Using public testnet RPCs can be slow because many tutorials wait for transactio - 🌉 [Bridging a custom token through the generic-custom gateway](./packages/custom-token-bridging/) - 🌉 [Bridging a custom token through a custom gateway](./packages/custom-gateway-bridging/) - ✈️ [Send a signed transaction from the parent chain](./packages/delayedInbox-l2msg/) +- 🛡️ [Force inclusion end-to-end test](./packages/force-inclusion/) - 🎁 [Redeem pending retryable ticket](./packages/redeem-pending-retryable/) - 🧮 [Gas estimation](./packages/gas-estimation/) - 🌀 [Deposit Ether or Tokens from L1 to L3](./packages/l1-l3-teleport/) diff --git a/packages/force-inclusion/.env-sample b/packages/force-inclusion/.env-sample new file mode 100644 index 0000000..b77b1f6 --- /dev/null +++ b/packages/force-inclusion/.env-sample @@ -0,0 +1,18 @@ +# This is a sample .env file for use in local development. +# Duplicate this file as .env here + +# Private key of the deployer (must have ETH on the parent chain) +DEPLOYER_PRIVATE_KEY="0x your key here" + +# The parent chain's RPC +# (default: Arbitrum Sepolia) +PARENT_CHAIN_RPC="https://sepolia-rollup.arbitrum.io/rpc" + +# (Optional) Parent chain ID +# Defaults to Arbitrum Sepolia (421614) if not set +# PARENT_CHAIN_ID=421614 + +# (Optional) Batch poster and validator keys +# Auto-generated if not set (only needed for --with-node) +# BATCH_POSTER_PRIVATE_KEY="0x..." +# VALIDATOR_PRIVATE_KEY="0x..." diff --git a/packages/force-inclusion/README.md b/packages/force-inclusion/README.md new file mode 100644 index 0000000..44c4a7b --- /dev/null +++ b/packages/force-inclusion/README.md @@ -0,0 +1,92 @@ +# Tutorial: Force Inclusion End-to-End Test + +`force-inclusion` demonstrates Arbitrum's **censorship resistance** mechanism end-to-end. It deploys a fresh Orbit rollup with a short force inclusion delay (90 seconds), deposits ETH via the delayed inbox, waits for the delay window to pass, and then force includes the deposit — all without a running sequencer. + +## What is force inclusion? + +Arbitrum's [Sequencer](https://docs.arbitrum.io/how-arbitrum-works/sequencer) normally orders transactions. But what if the sequencer goes offline or starts censoring? Arbitrum guarantees that any message sent to the **delayed inbox** on the parent chain can be **force included** into the chain's inbox after a time delay, bypassing the sequencer entirely. + +This tutorial runs the full cycle in a single command: + +1. **Deploy** a new Orbit rollup with `maxTimeVariation.delaySeconds = 90` (instead of the default 24 hours) +2. **Deposit** ETH via `Inbox.depositEth()` on the parent chain (goes into the delayed inbox) +3. **Force include** the deposit by calling `SequencerInbox.forceInclusion()` after the delay window passes +4. *(Optional)* **Start a fullnode** (no sequencer) to verify the deposit appears on the child chain + +## Prerequisites + +- Node.js 18+ +- A deployer account with ETH on the parent chain (Arbitrum Sepolia or a custom parent chain) +- Docker (only needed for the `--with-node` option) + +## Set environment variables + +Copy the sample env file and fill in your values: + +```bash +cp .env-sample .env +``` + +Required variables: + +| Variable | Description | +|---|---| +| `DEPLOYER_PRIVATE_KEY` | Private key of the deployer (must have ETH on the parent chain) | +| `PARENT_CHAIN_RPC` | RPC URL of the parent chain | + +Optional variables: + +| Variable | Description | +|---|---| +| `PARENT_CHAIN_ID` | Parent chain ID (defaults to Arbitrum Sepolia 421614) | +| `BATCH_POSTER_PRIVATE_KEY` | Batch poster key (auto-generated if not set) | +| `VALIDATOR_PRIVATE_KEY` | Validator key (auto-generated if not set) | + +## Run + +Run steps 1–3 (deploy, deposit, force include): + +```bash +yarn test +``` + +Run all 4 steps including fullnode verification (requires Docker): + +```bash +yarn test:withNode +``` + +## How it works + +### Rollup deployment + +The script uses `@arbitrum/chain-sdk` (Orbit SDK) to deploy a new rollup. The key configuration is `sequencerInboxMaxTimeVariation`, which controls how long a delayed message must wait before it can be force included: + +```js +sequencerInboxMaxTimeVariation: { + delayBlocks: 6n, + futureBlocks: 12n, + delaySeconds: 90n, + futureSeconds: 3600n, +} +``` + +### Delayed inbox deposit + +ETH is deposited using `@arbitrum/sdk`'s `EthBridger.deposit()`. Under the hood, this calls `Inbox.depositEth()` on the parent chain, which routes through `bridge.enqueueDelayedMessage()`. Without a running sequencer, this message stays in the delayed inbox. + +### Force inclusion + +After the delay window passes (90 seconds + 6 blocks), `InboxTools.forceInclude()` calls `SequencerInbox.forceInclusion()` on the parent chain. This emits the same `SequencerBatchDelivered` event as a normal sequencer batch, making the deposit part of the canonical chain. + +### Fullnode verification (--with-node) + +When `--with-node` is passed, the script starts a Nitro fullnode via Docker with the sequencer disabled (`node.sequencer = false`, `execution.forwarding-target = "null"`). The fullnode reads from the parent chain and processes the force-included batch, allowing you to verify that the deposited ETH appears on the child chain. + +## Related tutorials + +- [Send a signed transaction from the parent chain](../delayedInbox-l2msg/) — demonstrates sending transactions via the delayed inbox with a running sequencer + +

+ +

diff --git a/packages/force-inclusion/package.json b/packages/force-inclusion/package.json new file mode 100644 index 0000000..57cd9af --- /dev/null +++ b/packages/force-inclusion/package.json @@ -0,0 +1,16 @@ +{ + "name": "force-inclusion", + "version": "1.0.0", + "description": "End-to-end force inclusion test: deploy a rollup with a short delay, deposit ETH via delayed inbox, and force include — all without a sequencer.", + "scripts": { + "test": "node scripts/force-inclusion-test.js", + "test:withNode": "node scripts/force-inclusion-test.js --with-node" + }, + "author": "Offchain Labs, Inc.", + "license": "Apache-2.0", + "dependencies": { + "@arbitrum/sdk": "^v4.0.1", + "@arbitrum/chain-sdk": "^0.25.0", + "viem": "^1.20.0" + } +} diff --git a/packages/force-inclusion/scripts/force-inclusion-test.js b/packages/force-inclusion/scripts/force-inclusion-test.js new file mode 100644 index 0000000..c95134d --- /dev/null +++ b/packages/force-inclusion/scripts/force-inclusion-test.js @@ -0,0 +1,463 @@ +/** + * Force Inclusion End-to-End Test + * + * This script runs the full force inclusion cycle in one command: + * 1. Deploy a new Orbit rollup with a short force inclusion delay (90s) + * 2. Deposit ETH via the delayed inbox on the parent chain + * 3. Wait for the delay window, then force include the deposit + * 4. (Optional, --with-node) Start a fullnode to verify the L2 state + * + * Usage: + * node scripts/force-inclusion-test.js # Steps 1-3 + * node scripts/force-inclusion-test.js --with-node # Steps 1-4 (requires docker) + * + * Required environment variables: + * DEPLOYER_PRIVATE_KEY - Private key of the deployer (must have ETH on parent chain) + * PARENT_CHAIN_RPC - RPC URL of the parent chain + * + * Optional: + * PARENT_CHAIN_ID - Parent chain ID (default: Arbitrum Sepolia) + */ + +const { createPublicClient, http, defineChain } = require('viem'); +const { privateKeyToAccount, generatePrivateKey } = require('viem/accounts'); +const { arbitrumSepolia } = require('viem/chains'); +const { + createRollupPrepareDeploymentParamsConfig, + prepareChainConfig, + createRollupPrepareTransactionRequest, + createRollupPrepareTransactionReceipt, + prepareNodeConfig, +} = require('@arbitrum/chain-sdk'); +const { generateChainId, sanitizePrivateKey } = require('@arbitrum/chain-sdk/utils'); +const { + SequencerInbox__factory, +} = require('@arbitrum/sdk/dist/lib/abi/factories/SequencerInbox__factory'); +const { utils, providers, Wallet } = require('ethers'); +const { EthBridger, InboxTools, registerCustomArbitrumNetwork } = require('@arbitrum/sdk'); +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +require('dotenv').config(); + +// ============================================================ +// Config +// ============================================================ + +const WITH_NODE = process.argv.includes('--with-node'); + +const FORCE_INCLUSION_DELAY_SECONDS = 72n; +const FORCE_INCLUSION_DELAY_BLOCKS = 6n; +const FUTURE_SECONDS = 3600n; +const FUTURE_BLOCKS = 12n; + +const DEPOSIT_AMOUNT = utils.parseEther('0.01'); +const POLL_INTERVAL_SECONDS = 10; +const FULLNODE_RPC = 'http://localhost:8449'; +const SYNC_TIMEOUT_MS = 180_000; + +// ============================================================ +// Helpers +// ============================================================ + +function requireEnv(name) { + if (!process.env[name]) { + throw new Error(`Please provide the "${name}" environment variable`); + } + return process.env[name]; +} + +function getParentChain(parentChainRpc) { + if (process.env.PARENT_CHAIN_ID) { + const id = Number(process.env.PARENT_CHAIN_ID); + if (id === arbitrumSepolia.id) return { chain: arbitrumSepolia, isCustom: false }; + return { + chain: defineChain({ + id, + name: 'Custom Parent Chain', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [parentChainRpc] } }, + }), + isCustom: true, + }; + } + return { chain: arbitrumSepolia, isCustom: false }; +} + +// ============================================================ +// Step 1: Deploy Rollup +// ============================================================ + +async function deployRollup() { + console.log('\n========================================'); + console.log(' Step 1: Deploy Orbit Rollup'); + console.log('========================================\n'); + + const deployerPrivateKey = sanitizePrivateKey(requireEnv('DEPLOYER_PRIVATE_KEY')); + const deployer = privateKeyToAccount(deployerPrivateKey); + const parentChainRpc = requireEnv('PARENT_CHAIN_RPC'); + + console.log(`Deployer: ${deployer.address}`); + + // Generate batch poster and validator keys (required for deployment but not used) + const batchPosterPrivateKey = process.env.BATCH_POSTER_PRIVATE_KEY + ? sanitizePrivateKey(process.env.BATCH_POSTER_PRIVATE_KEY) + : generatePrivateKey(); + const batchPoster = privateKeyToAccount(batchPosterPrivateKey).address; + + const validatorPrivateKey = process.env.VALIDATOR_PRIVATE_KEY + ? sanitizePrivateKey(process.env.VALIDATOR_PRIVATE_KEY) + : generatePrivateKey(); + const validator = privateKeyToAccount(validatorPrivateKey).address; + + const { chain: parentChain, isCustom: parentChainIsCustom } = getParentChain(parentChainRpc); + const parentChainPublicClient = createPublicClient({ + chain: parentChain, + transport: http(parentChainRpc), + }); + + const chainId = generateChainId(); + console.log(`New rollup chain ID: ${chainId}`); + console.log( + `Force inclusion delay: ${FORCE_INCLUSION_DELAY_SECONDS}s / ${FORCE_INCLUSION_DELAY_BLOCKS} blocks`, + ); + + const chainConfig = prepareChainConfig({ + chainId, + arbitrum: { + InitialChainOwner: deployer.address, + DataAvailabilityCommittee: false, + }, + }); + + const deploymentParamsConfig = { + chainId: BigInt(chainId), + owner: deployer.address, + chainConfig, + sequencerInboxMaxTimeVariation: { + delayBlocks: FORCE_INCLUSION_DELAY_BLOCKS, + futureBlocks: FUTURE_BLOCKS, + delaySeconds: FORCE_INCLUSION_DELAY_SECONDS, + futureSeconds: FUTURE_SECONDS, + }, + }; + + if (parentChainIsCustom) { + deploymentParamsConfig.confirmPeriodBlocks = 150n; + } + + const createRollupConfig = createRollupPrepareDeploymentParamsConfig( + parentChainPublicClient, + deploymentParamsConfig, + ); + + console.log('\nPreparing deployment transaction...'); + const request = await createRollupPrepareTransactionRequest({ + params: { + config: createRollupConfig, + batchPosters: [batchPoster], + validators: [validator], + }, + account: deployer.address, + publicClient: parentChainPublicClient, + }); + + console.log('Sending deployment transaction...'); + const txHash = await parentChainPublicClient.sendRawTransaction({ + serializedTransaction: await deployer.signTransaction(request), + }); + console.log(`Transaction hash: ${txHash}`); + + console.log('Waiting for confirmation...'); + const txReceipt = createRollupPrepareTransactionReceipt( + await parentChainPublicClient.waitForTransactionReceipt({ hash: txHash }), + ); + + const coreContracts = txReceipt.getCoreContracts(); + console.log('\nRollup deployed!'); + console.log(` Rollup: ${coreContracts.rollup}`); + console.log(` Inbox: ${coreContracts.inbox}`); + console.log(` SequencerInbox: ${coreContracts.sequencerInbox}`); + console.log(` Bridge: ${coreContracts.bridge}`); + + // Generate fullnode config (for optional --with-node step) + const nodeConfig = prepareNodeConfig({ + chainName: 'ForceInclusionTestChain', + chainConfig, + coreContracts, + batchPosterPrivateKey, + validatorPrivateKey, + stakeToken: '0x0000000000000000000000000000000000000000', + parentChainId: parentChain.id, + parentChainRpcUrl: parentChainRpc, + }); + nodeConfig.node.sequencer = false; + nodeConfig.node['delayed-sequencer'] = { enable: false }; + nodeConfig.node['batch-poster'] = { enable: false }; + nodeConfig.node.staker = { enable: false }; + nodeConfig.execution.sequencer = { enable: false }; + nodeConfig.execution['forwarding-target'] = 'null'; + nodeConfig['ensure-rollup-deployment'] = false; + + const nodeConfigPath = path.join(__dirname, '..', 'fullnode-config.json'); + fs.writeFileSync(nodeConfigPath, JSON.stringify(nodeConfig, null, 2)); + + return { + chainId, + parentChainId: parentChain.id, + coreContracts, + chainConfig, + nodeConfigPath, + }; +} + +// ============================================================ +// Step 2: Deposit ETH via Delayed Inbox +// ============================================================ + +async function depositEth(chainId, coreContracts, parentChainId) { + console.log('\n========================================'); + console.log(' Step 2: Deposit ETH via Delayed Inbox'); + console.log('========================================\n'); + + const parentChainProvider = new providers.JsonRpcProvider(requireEnv('PARENT_CHAIN_RPC')); + const parentChainWallet = new Wallet(requireEnv('DEPLOYER_PRIVATE_KEY'), parentChainProvider); + + // Register the custom network with Arbitrum SDK (v4 API) + const childChainNetwork = registerCustomArbitrumNetwork({ + chainId, + parentChainId, + confirmPeriodBlocks: 150, + ethBridge: { + bridge: coreContracts.bridge, + inbox: coreContracts.inbox, + outbox: coreContracts.outbox, + rollup: coreContracts.rollup, + sequencerInbox: coreContracts.sequencerInbox, + }, + isCustom: true, + isTestnet: true, + name: 'ForceInclusionTestChain', + }); + const ethBridger = new EthBridger(childChainNetwork); + + console.log(`Depositing ${utils.formatEther(DEPOSIT_AMOUNT)} ETH to the delayed inbox...`); + console.log(`Depositor: ${parentChainWallet.address}`); + + const depositTx = await ethBridger.deposit({ + amount: DEPOSIT_AMOUNT, + parentSigner: parentChainWallet, + childProvider: parentChainProvider, + }); + + const depositReceipt = await depositTx.wait(); + console.log(`\nDeposit confirmed! TX: ${depositReceipt.transactionHash}`); + console.log('The deposit is now in the delayed inbox. No sequencer will pick it up.'); + + return childChainNetwork; +} + +// ============================================================ +// Step 3: Force Include +// ============================================================ + +async function forceInclude(childChainNetwork) { + console.log('\n========================================'); + console.log(' Step 3: Force Include'); + console.log('========================================\n'); + + const parentChainProvider = new providers.JsonRpcProvider(requireEnv('PARENT_CHAIN_RPC')); + const parentChainWallet = new Wallet(requireEnv('DEPLOYER_PRIVATE_KEY'), parentChainProvider); + + const inboxTools = new InboxTools(parentChainWallet, childChainNetwork); + + console.log(`Waiting for force inclusion delay (~${FORCE_INCLUSION_DELAY_SECONDS}s) to pass...`); + console.log('Polling for force-includable events...\n'); + + let forceIncludableEvent = null; + while (!forceIncludableEvent) { + try { + // eslint-disable-next-line no-await-in-loop + forceIncludableEvent = await inboxTools.getForceIncludableEvent(); + } catch (e) { + console.log( + ` Query failed (likely L1 block not yet mapped). Retrying in ${POLL_INTERVAL_SECONDS}s...`, + ); + } + if (!forceIncludableEvent) { + console.log(` No eligible messages yet. Retrying in ${POLL_INTERVAL_SECONDS}s...`); + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, POLL_INTERVAL_SECONDS * 1000)); + } + } + + console.log('Found force-includable event!'); + console.log(` Message index: ${forceIncludableEvent.event.messageIndex.toString()}`); + + console.log('\nCalling forceInclude()...'); + const tx = await inboxTools.forceInclude(forceIncludableEvent); + const receipt = await tx.wait(); + + console.log(`\nForce inclusion successful!`); + console.log(` TX: ${receipt.transactionHash}`); + + // Verify totalDelayedMessagesRead + + const sequencerInbox = SequencerInbox__factory.connect( + childChainNetwork.ethBridge.sequencerInbox, + parentChainProvider, + ); + const totalRead = await sequencerInbox.totalDelayedMessagesRead(); + console.log(` totalDelayedMessagesRead: ${totalRead.toString()}`); +} + +// ============================================================ +// Step 4 (Optional): Verify on Fullnode +// ============================================================ + +async function verifyOnFullnode(nodeConfigPath) { + console.log('\n========================================'); + console.log(' Step 4: Verify on Fullnode (--with-node)'); + console.log('========================================\n'); + + if (!fs.existsSync(nodeConfigPath)) { + console.error('fullnode-config.json not found.'); + return; + } + + console.log('Starting fullnode via docker (sequencer disabled)...'); + let dockerContainerId; + try { + dockerContainerId = execSync( + `docker run -d ` + + `-v ${nodeConfigPath}:/config/nodeConfig.json ` + + `-p 8449:8449 ` + + `offchainlabs/nitro-node:v3.9.5-66e42c4 ` + + `--conf.file /config/nodeConfig.json`, + { encoding: 'utf8' }, + ).trim(); + console.log(`Container started: ${dockerContainerId.substring(0, 12)}`); + + // Wait briefly then verify the container is still running + await new Promise((r) => setTimeout(r, 3000)); + const running = execSync( + `docker ps -q --filter id=${dockerContainerId}`, + { encoding: 'utf8' }, + ).trim(); + if (!running) { + console.error('Container exited immediately. Logs:'); + try { + console.error(execSync(`docker logs ${dockerContainerId}`, { encoding: 'utf8' })); + } catch (_) { /* ignore */ } + try { execSync(`docker rm ${dockerContainerId}`, { encoding: 'utf8' }); } catch (_) { /* ignore */ } + return; + } + + console.log('Waiting for node to initialize...\n'); + } catch (error) { + console.error('Failed to start docker container. Is docker running?'); + console.error(error.message); + return; + } + + try { + console.log(`Connecting to fullnode RPC: ${FULLNODE_RPC}`); + let childChainProvider; + let connected = false; + const connectStart = Date.now(); + while (Date.now() - connectStart < SYNC_TIMEOUT_MS) { + try { + childChainProvider = new providers.JsonRpcProvider(FULLNODE_RPC); + // eslint-disable-next-line no-await-in-loop + const network = await childChainProvider.getNetwork(); + console.log(`Connected! Chain ID: ${network.chainId}\n`); + connected = true; + break; + } catch (e) { + console.log(' Node not ready yet, retrying in 5s...'); + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 5_000)); + } + } + if (!connected) { + console.error('\nTimeout: could not connect to fullnode RPC.'); + return; + } + const wallet = new Wallet(requireEnv('DEPLOYER_PRIVATE_KEY'), childChainProvider); + + console.log('Waiting for fullnode to sync...'); + const startTime = Date.now(); + let latestBlock; + while (Date.now() - startTime < SYNC_TIMEOUT_MS) { + latestBlock = await childChainProvider.getBlock('latest'); + if (latestBlock.number > 0) break; + console.log(` Block: ${latestBlock.number}, waiting...`); + await new Promise((r) => setTimeout(r, 5_000)); + } + + if (!latestBlock || latestBlock.number === 0) { + console.error('\nTimeout: fullnode did not produce blocks.'); + return; + } + + console.log(`Fullnode synced! Latest block: ${latestBlock.number}`); + + const balance = await wallet.getBalance(); + console.log(`\nWallet: ${wallet.address}`); + console.log(`Balance on child chain: ${utils.formatEther(balance)} ETH`); + + if (balance.gt(0)) { + console.log('\nETH deposit verified on child chain! Force inclusion works end-to-end.'); + } else { + console.log('\nBalance is 0. Fullnode may need more time to process.'); + } + } finally { + console.log(`\nStopping container ${dockerContainerId.substring(0, 12)}...`); + try { + execSync(`docker stop ${dockerContainerId}`, { encoding: 'utf8' }); + } catch (e) { + /* container may have already stopped */ + } + try { + execSync(`docker rm ${dockerContainerId}`, { encoding: 'utf8' }); + } catch (e) { + /* container may have already been removed */ + } + } +} + +// ============================================================ +// Main +// ============================================================ + +async function main() { + console.log('=== Force Inclusion End-to-End Test ==='); + console.log( + `Mode: ${ + WITH_NODE ? 'full (with fullnode verification)' : 'deploy + deposit + forceInclude' + }\n`, + ); + + // Step 1: Deploy + const { chainId, parentChainId, coreContracts, nodeConfigPath } = await deployRollup(); + + // Step 2: Deposit + const childChainNetwork = await depositEth(chainId, coreContracts, parentChainId); + + // Step 3: Force Include + await forceInclude(childChainNetwork); + + // Step 4: (Optional) Verify on fullnode + if (WITH_NODE) { + await verifyOnFullnode(nodeConfigPath); + } + + console.log('\n=== Done ==='); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + });