diff --git a/.github/workflows/bridge-e2e.yml b/.github/workflows/bridge-e2e.yml index 3a84b21671ed..3c099642a174 100644 --- a/.github/workflows/bridge-e2e.yml +++ b/.github/workflows/bridge-e2e.yml @@ -121,10 +121,19 @@ jobs: load: true tags: linera-bridge:latest - - name: Build example Wasm for bridge tests + - name: Push bridge image to registry + if: steps.build-bridge.outcome == 'success' + run: | + BRANCH="${{ github.base_ref || github.ref_name }}" + docker push "${GCP_REGISTRY}/linera-bridge:${BRANCH}" + + - name: Build example Wasm modules for bridge tests run: cargo build --release --target wasm32-unknown-unknown -p wrapped-fungible -p evm-bridge working-directory: examples + - name: Build linera-bridge binary for local relay tests + run: cargo build -p linera-bridge --features linera-bridge/relay + - name: Run bridge E2E test working-directory: linera-bridge/tests/e2e env: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0c32408994f4..7b77e731f717 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -213,14 +213,14 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Compile Wasm test modules for Witty integration tests run: | - cargo build -p linera-witty-test-modules --target wasm32-unknown-unknown + cargo build -p linera-witty-test-modules --target wasm32-unknown-unknown --locked - name: Run all tests using the default features (except storage-service) run: | # TODO(#2764): Actually link this to the default features cargo test --no-default-features --features fs,macros,wasmer,rocksdb --locked - name: Run Witty integration tests run: | - cargo test -p linera-witty --features wasmer,wasmtime + cargo test -p linera-witty --features wasmer,wasmtime --locked check-outdated-cli-md: needs: changed-files diff --git a/CLI.md b/CLI.md index 5f0ca4d206d1..e62a6b792d33 100644 --- a/CLI.md +++ b/CLI.md @@ -1281,6 +1281,7 @@ Start a Local Linera Network * `--exporter-port ` — The port on which to run the block exporter Default value: `8081` +* `--http-request-allow-list ` — Set the list of hosts that contracts and services can send HTTP requests to diff --git a/Cargo.lock b/Cargo.lock index 25533790cd74..b3e37301049c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5465,7 +5465,9 @@ dependencies = [ "axum", "bcs", "clap", + "dirs", "fs-err", + "fungible", "futures", "hex", "insta", @@ -5475,9 +5477,11 @@ dependencies = [ "linera-core", "linera-execution", "linera-faucet-client", + "linera-persistent", "linera-storage", "linera-views", "op-alloy-network", + "prometheus", "proptest", "rand 0.8.5", "rand_chacha 0.3.1", @@ -5490,7 +5494,10 @@ dependencies = [ "serde-reflection", "serde_json", "serde_yaml 0.8.26", + "sqlx", "tempfile", + "test-case", + "thiserror 1.0.69", "tokio", "tower-http 0.6.6", "tracing", diff --git a/docker/Dockerfile.bridge b/docker/Dockerfile.bridge index fe2ae465f5cf..f5dff8b798f3 100644 --- a/docker/Dockerfile.bridge +++ b/docker/Dockerfile.bridge @@ -99,3 +99,40 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ RUN apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/local/bin/linera-bridge /usr/local/bin/linera-bridge +COPY docker/bridge-entrypoint.sh /usr/local/bin/bridge-entrypoint.sh +RUN chmod +x /usr/local/bin/bridge-entrypoint.sh + +# ── Required (no defaults — must be set at runtime) ── +# RPC_URL EVM JSON-RPC endpoint +# EVM_BRIDGE_ADDRESS FungibleBridge contract address on EVM +# LINERA_BRIDGE_APP evm-bridge Linera ApplicationId (hex) +# LINERA_FUNGIBLE_APP wrapped-fungible Linera ApplicationId (hex) +# EVM_PRIVATE_KEY EVM private key for signing transactions + +# ── Conditionally required ── +# FAUCET_URL Linera faucet URL (required when wallet doesn't exist or chain ID not provided) + +# ── Optional (have defaults) ── +ENV PORT=3001 +ENV MONITOR_SCAN_INTERVAL=30 +ENV MONITOR_START_BLOCK=0 +ENV MAX_RETRIES=10 +ENV BLOB_CACHE_SIZE=1000 +ENV CONFIRMED_BLOCK_CACHE_SIZE=1000 +ENV LITE_CERTIFICATE_CACHE_SIZE=1000 +ENV CERTIFICATE_RAW_CACHE_SIZE=1000 +ENV EVENT_CACHE_SIZE=1000 + +# ── Optional Linera client paths (clap reads these directly) ── +# LINERA_WALLET Path to wallet state file +# LINERA_KEYSTORE Path to keystore file +# LINERA_STORAGE Storage config (e.g. rocksdb:/data/client.db) + +# ── Optional ── +# LINERA_BRIDGE_CHAIN_ID Linera bridge chain ID (claims new if omitted) +# LINERA_BRIDGE_CHAIN_OWNER Owner for the bridge chain (required with LINERA_BRIDGE_CHAIN_ID) + +EXPOSE 3001 + +ENTRYPOINT ["bridge-entrypoint.sh"] +CMD ["serve"] diff --git a/docker/bridge-entrypoint.sh b/docker/bridge-entrypoint.sh new file mode 100644 index 000000000000..7cfc383013f2 --- /dev/null +++ b/docker/bridge-entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -e + +# If the first argument is "serve" with no other args, build CLI from env vars. +# Otherwise, pass everything through as-is (supports "sh -c ...", direct CLI usage, etc). +if [ "$1" != "serve" ]; then + exec "$@" +fi + +# Build the CLI invocation from environment variables. +shift # consume "serve" + +set -- linera-bridge serve \ + --rpc-url="${RPC_URL:?RPC_URL is required}" \ + --evm-bridge-address="${EVM_BRIDGE_ADDRESS:?EVM_BRIDGE_ADDRESS is required}" \ + --linera-bridge-address="${LINERA_BRIDGE_APP:?LINERA_BRIDGE_APP is required}" \ + --linera-fungible-address="${LINERA_FUNGIBLE_APP:?LINERA_FUNGIBLE_APP is required}" \ + --evm-private-key="${EVM_PRIVATE_KEY:?EVM_PRIVATE_KEY is required}" \ + --port="${PORT:-3001}" \ + --monitor-scan-interval="${MONITOR_SCAN_INTERVAL:-30}" \ + --monitor-start-block="${MONITOR_START_BLOCK:-0}" \ + --max-retries="${MAX_RETRIES:-10}" \ + --blob-cache-size="${BLOB_CACHE_SIZE:-1000}" \ + --confirmed-block-cache-size="${CONFIRMED_BLOCK_CACHE_SIZE:-1000}" \ + --lite-certificate-cache-size="${LITE_CERTIFICATE_CACHE_SIZE:-1000}" \ + --certificate-raw-cache-size="${CERTIFICATE_RAW_CACHE_SIZE:-1000}" \ + --event-cache-size="${EVENT_CACHE_SIZE:-1000}" + +# Optional: faucet URL (required when wallet doesn't exist or chain ID not provided) +if [ -n "$FAUCET_URL" ]; then + set -- "$@" --faucet-url="$FAUCET_URL" +fi + +# Optional: bridge chain ID + owner (owner is required when chain ID is provided) +if [ -n "$LINERA_BRIDGE_CHAIN_ID" ]; then + set -- "$@" --linera-bridge-chain-id="$LINERA_BRIDGE_CHAIN_ID" + set -- "$@" --linera-bridge-chain-owner="${LINERA_BRIDGE_CHAIN_OWNER:?LINERA_BRIDGE_CHAIN_OWNER is required when LINERA_BRIDGE_CHAIN_ID is set}" +fi + +# LINERA_WALLET, LINERA_KEYSTORE, LINERA_STORAGE are read directly by clap +# via `env = "..."`, so they don't need explicit --flags here. + +exec "$@" diff --git a/docker/docker-compose.bridge-test.yml b/docker/docker-compose.bridge-test.yml index 3b4ee0b51d72..8212df94c29e 100644 --- a/docker/docker-compose.bridge-test.yml +++ b/docker/docker-compose.bridge-test.yml @@ -2,7 +2,7 @@ services: # 0. Anvil - local EVM node for LightClient contract anvil: image: ghcr.io/foundry-rs/foundry:stable - entrypoint: ["anvil", "--host", "0.0.0.0", "--code-size-limit", "300000", "--hardfork", "shanghai"] + entrypoint: ["anvil", "--host", "0.0.0.0", "--code-size-limit", "300000", "--hardfork", "shanghai", "--slots-in-an-epoch", "1", "--block-time", "1"] ports: - "${ANVIL_PORT:-8545}:8545" healthcheck: @@ -70,22 +70,27 @@ services: - "13001:13001" - "9090:9090" - "${RELAY_PORT:-3001}:${RELAY_PORT:-3001}" - command: > - sh -c "mkdir -p ${LINERA_NET_PATH:-/tmp/wallet} && - ./linera net - --storage scylladb:tcp:scylla:9042:table_default - up - --path ${LINERA_NET_PATH:-/tmp/wallet} - --with-faucet - --faucet-port ${FAUCET_PORT:-8080} - --with-block-exporter - --exporter-address linera-block-exporter - --exporter-port ${BLOCK_EXPORTER_PORT:-8882}" + command: + - sh + - -c + - | + mkdir -p ${LINERA_NET_PATH:-/tmp/wallet} && + ./linera net \ + --storage scylladb:tcp:scylla:9042:table_default \ + up \ + --path ${LINERA_NET_PATH:-/tmp/wallet} \ + --with-faucet \ + --faucet-port ${FAUCET_PORT:-8080} \ + --with-block-exporter \ + --exporter-address linera-block-exporter \ + --exporter-port ${BLOCK_EXPORTER_PORT:-8882} \ + $${HTTP_REQUEST_ALLOW_LIST:+--http-request-allow-list $$HTTP_REQUEST_ALLOW_LIST} environment: - RUST_LOG=linera=info - FAUCET_PORT=${FAUCET_PORT:-8080} - BLOCK_EXPORTER_PORT=${BLOCK_EXPORTER_PORT:-8882} - LINERA_NET_PATH=${LINERA_NET_PATH:-/tmp/wallet} + - HTTP_REQUEST_ALLOW_LIST=${HTTP_REQUEST_ALLOW_LIST:-anvil} healthcheck: test: ["CMD-SHELL", "bash -c 'cat < /dev/null > /dev/tcp/localhost/'${FAUCET_PORT:-8080}"] interval: 5s @@ -217,6 +222,7 @@ services: image: "${LINERA_EXPORTER_IMAGE:-linera-exporter}" ports: - "${BLOCK_EXPORTER_PORT:-8882}:${BLOCK_EXPORTER_PORT:-8882}" + - "${EXPORTER_METRICS_PORT:-9091}:9091" command: - sh - -c @@ -258,7 +264,7 @@ services: bridge-init: condition: service_completed_successfully healthcheck: - test: ["CMD-SHELL", "nc -z localhost ${BLOCK_EXPORTER_PORT:-8882}"] + test: ["CMD-SHELL", "wget -qO- http://localhost:9091/health || exit 1"] interval: 5s timeout: 3s retries: 10 @@ -266,28 +272,91 @@ services: networks: - linera-network - # 6. Relay server - claims bridge chain, processes inbox, forwards blocks to EVM - # Bridge address is resolved from /shared/bridge-address (written by setup script). + # 6a. Bridge chain init - claims a chain for the relay before the relay starts. + # Writes bridge-chain-id and relay-owner to /shared/ so the setup script + # and the relay can use them. + bridge-chain-init: + image: "${LINERA_NETWORK_IMAGE:-linera-test}" + network_mode: "service:linera-network" + command: + - sh + - -c + - | + set -e + FAUCET=http://localhost:${FAUCET_PORT:-8080} + RELAY_WALLET=/shared/relay-wallet + + echo "Initializing relay wallet..." + mkdir -p $$RELAY_WALLET + LINERA_WALLET=$$RELAY_WALLET/wallet.json \ + LINERA_KEYSTORE=$$RELAY_WALLET/keystore.json \ + LINERA_STORAGE=rocksdb:$$RELAY_WALLET/client.db \ + ./linera wallet init --faucet "$$FAUCET" + + echo "Claiming bridge chain..." + OUTPUT=$$(LINERA_WALLET=$$RELAY_WALLET/wallet.json \ + LINERA_KEYSTORE=$$RELAY_WALLET/keystore.json \ + LINERA_STORAGE=rocksdb:$$RELAY_WALLET/client.db \ + ./linera wallet request-chain --faucet "$$FAUCET" 2>&1) + echo "$$OUTPUT" + + # Extract chain ID (64-char hex on its own line) + CHAIN_ID=$$(echo "$$OUTPUT" | grep -oE '^[a-f0-9]{64}$$' | tail -1) + # Extract owner (the AccountOwner printed by request-chain) + OWNER=$$(echo "$$OUTPUT" | grep -oE '0x[a-f0-9]{64}' | tail -1) + + echo "Bridge chain: $$CHAIN_ID" + echo "Relay owner: $$OWNER" + echo "$$CHAIN_ID" > /shared/bridge-chain-id + echo "$$OWNER" > /shared/relay-owner + environment: + - FAUCET_PORT=${FAUCET_PORT:-8080} + volumes: + - bridge-shared:/shared + depends_on: + linera-network: + condition: service_healthy + + # 6b. Relay server - processes inbox, forwards blocks to EVM. + # Uses the chain claimed by bridge-chain-init. + # Bridge address is resolved from /shared/bridge-address (written by setup script). # Uses network_mode: service:linera-network so that localhost:13001 reaches the # validator (whose address is hardcoded as "localhost" in the genesis config). linera-relay: image: "${LINERA_BRIDGE_IMAGE:-linera-bridge}" network_mode: "service:linera-network" + entrypoint: ["sh", "-c"] command: - - linera-bridge - - serve - - --rpc-url=http://anvil:8545 - - --faucet-url=http://localhost:${FAUCET_PORT:-8080} - - --bridge-address-file=/shared/bridge-address - - --evm-private-key=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - - --port=${RELAY_PORT:-3001} + - | + rm -f /shared/setup-complete + echo "Waiting for setup to complete..." + while [ ! -f /shared/setup-complete ]; do sleep 1; done + + export RPC_URL=http://anvil:8545 + export FAUCET_URL=http://localhost:${FAUCET_PORT:-8080} + export EVM_BRIDGE_ADDRESS=$$(cat /shared/bridge-address | tr -d '[:space:]') + export LINERA_BRIDGE_APP=$$(cat /shared/bridge-app-id | tr -d '[:space:]') + export LINERA_FUNGIBLE_APP=$$(cat /shared/wrapped-app-id | tr -d '[:space:]') + export LINERA_BRIDGE_CHAIN_ID=$$(cat /shared/bridge-chain-id | tr -d '[:space:]') + export LINERA_BRIDGE_CHAIN_OWNER=$$(cat /shared/relay-owner | tr -d '[:space:]') + export EVM_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + export LINERA_WALLET=/shared/relay-wallet/wallet.json + export LINERA_KEYSTORE=/shared/relay-wallet/keystore.json + export LINERA_STORAGE=rocksdb:/shared/relay-wallet/client.db + + echo "Starting relay: chain=$$LINERA_BRIDGE_CHAIN_ID bridge=$$EVM_BRIDGE_ADDRESS" + exec bridge-entrypoint.sh serve environment: - RUST_LOG=linera=info,linera_bridge=debug + - PORT=${RELAY_PORT:-3001} + - MONITOR_SCAN_INTERVAL=${MONITOR_SCAN_INTERVAL:-5} + - MONITOR_START_BLOCK=${MONITOR_START_BLOCK:-0} + - MAX_RETRIES=${MAX_RETRIES:-10} volumes: - bridge-shared:/shared depends_on: - linera-network: - condition: service_healthy + bridge-chain-init: + condition: service_completed_successfully anvil: condition: service_healthy diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 453bf6690868..eb6975ec10a1 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -2550,7 +2550,9 @@ dependencies = [ "fungible", "hex", "linera-bridge", + "linera-ethereum", "linera-sdk", + "log", "serde", "tokio", "wrapped-fungible", @@ -3858,6 +3860,7 @@ dependencies = [ "alloy-rlp", "alloy-trie", "anyhow", + "serde", ] [[package]] diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 12d0197c7014..ff8f30344a7a 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -40,6 +40,8 @@ getrandom = { version = "0.2.12", default-features = false, features = [ "custom", ] } hex = "0.4.3" +insta = { version = "1.36.1", features = ["yaml"] } +linera-ethereum = { path = "../linera-ethereum" } linera-sdk = { path = "../linera-sdk" } log = "0.4.20" num-bigint = "0.4.3" diff --git a/examples/bridge-demo/README.md b/examples/bridge-demo/README.md index df2e3ce73834..c096690dca6c 100644 --- a/examples/bridge-demo/README.md +++ b/examples/bridge-demo/README.md @@ -11,7 +11,7 @@ Three components cooperate: | Component | EVM side | Linera side | |-----------|----------|-------------| | **Contracts / Apps** | `LightClient` (verifies Linera blocks), `FungibleBridge` (holds ERC-20s, emits deposit events) | `wrapped-fungible` (mints/burns wrapped tokens), `evm-bridge` (coordinates messaging) | -| **Relay** | Watches for `DepositInitiated` events, submits receipt proofs | Claims a Linera chain, forwards blocks to EVM for withdrawals | +| **Relay** | Watches for `DepositInitiated` events, submits receipt proofs | Manages a Linera chain (persistent state), forwards blocks to EVM for withdrawals | | **Frontend** | MetaMask for EVM transactions | `@linera/client` for Linera queries and signing | ## Quick start (Docker) @@ -130,70 +130,83 @@ make build-wasm This compiles `fungible`, `wrapped-fungible`, and `evm-bridge` to `examples/target/wasm32-unknown-unknown/release/`. -### 2. Start the relay +### 2. Initialize wallet and claim a bridge chain -The relay claims a Linera chain and bridges events between EVM and Linera. It -needs to start first because `setup.sh` reads the bridge chain ID and relay -owner from files the relay writes. +```bash +export FAUCET_URL=https://faucet.testnet-conway.linera.net -Pick a shared directory for coordination files. The setup script generates a -timestamped directory by default (e.g. `/tmp/bridge-demo-20260313-112421`), so -you must use the same `SHARED_DIR` in both terminals: +# Initialize a Linera wallet from the faucet +linera wallet init --faucet "$FAUCET_URL" -```bash -export SHARED_DIR="/tmp/bridge-demo-$(date +%Y%m%d-%H%M%S)" -mkdir -p "$SHARED_DIR" +# Claim a chain that the relay will use as the "bridge chain" +linera wallet request-chain --faucet "$FAUCET_URL" ``` -> **Important:** Export the same `SHARED_DIR` value in both the relay terminal -> and the setup terminal. The relay polls this directory for app IDs written by -> `setup.sh`, and `setup.sh` reads the bridge chain ID written by the relay. +Note the **chain ID** and **owner** printed by `request-chain` — you'll need +them for both the setup script and the relay. -Start the relay (it will poll the shared dir for contract/app addresses that -`setup.sh` writes later): +### 3. Deploy contracts + +Pick a shared directory for coordination files between the setup script and +the relay: ```bash -linera-bridge serve \ - --rpc-url https://base-sepolia-rpc.publicnode.com \ - --faucet-url https://faucet.testnet-conway.linera.net \ - --evm-private-key 0x... \ - --bridge-address-file "$SHARED_DIR/bridge-address" \ - --bridge-app-id-file "$SHARED_DIR/bridge-app-id" \ - --fungible-app-id-file "$SHARED_DIR/wrapped-app-id" +export SHARED_DIR="/tmp/bridge-demo-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$SHARED_DIR" ``` -When the relay starts it prints its **bridge chain ID** and **AccountOwner**. -Copy both for the next step. - -### 3. Run the setup script - -In a second terminal: +Run the setup script to deploy EVM contracts and Linera apps: ```bash cd examples/bridge-demo ./setup.sh \ - --evm-rpc-url https://base-sepolia-rpc.publicnode.com \ - --evm-private-key 0xfce057d5e1a3f8265745c95b0a3847e03831f861bec0f7b47a8cd4800ac92aa1 \ + --evm-rpc-url https://base-sepolia.g.alchemy.com/v2/YOUR_KEY \ + --evm-private-key 0x... \ --evm-chain-id 84532 \ - --bridge-chain-id <64-hex-chain-id-from-relay> \ - --relay-owner \ - --faucet-url https://faucet.testnet-conway.linera.net \ - --relay-url http://localhost:3001 \ + --linera-bridge-chain-id \ + --relay-owner \ + --faucet-url "$FAUCET_URL" \ + --linera-wallet ~/.config/linera/wallet.json \ + --linera-keystore ~/.config/linera/keystore.json \ + --linera-storage rocksdb:~/.config/linera/client.db \ --shared-dir "$SHARED_DIR" ``` The setup script: -1. Initializes a Linera wallet from the faucet -2. Fetches validator info and deploys the **LightClient** contract -3. Deploys a **MockERC20** token (or pass `--token-address` to use an existing one) -4. Publishes **wrapped-fungible** and **evm-bridge** apps on the bridge chain -5. Deploys the **FungibleBridge** contract (referencing LightClient + apps) -6. Funds the bridge with ERC-20 tokens -7. Writes contract/app addresses to `$SHARED_DIR` (relay picks them up) and +1. Fetches validator info and deploys the **LightClient** contract +2. Deploys a **MockERC20** token (or pass `--token-address` to use an existing one) +3. Publishes **wrapped-fungible** and **evm-bridge** apps on the bridge chain +4. Deploys the **FungibleBridge** contract (referencing LightClient + apps) +5. Funds the bridge with ERC-20 tokens +6. Writes contract/app addresses to `$SHARED_DIR` (relay picks them up) and `.env.local` (frontend reads them) -### 4. Start the frontend +### 4. Start the relay + +The relay uses the same wallet, keystore, and storage as the `linera` CLI. +By default it reads from `~/.config/linera/` — the same location `linera +wallet init` writes to. You can override with `--wallet`, `--keystore`, +`--storage` flags or `LINERA_WALLET`, `LINERA_KEYSTORE`, `LINERA_STORAGE` +env vars. + +Pass the contract addresses and app IDs from the setup script output: + +```bash +linera-bridge serve \ + --rpc-url \ + --faucet-url "$FAUCET_URL" \ + --linera-bridge-chain-id \ + --linera-bridge-address \ + --linera-fungible-address \ + --evm-bridge-address \ + --evm-private-key +``` + +On restart, run the same command — the relay loads persistent state from +the wallet and storage, and syncs from validators to catch up. + +### 5. Start the frontend ```bash pnpm install && pnpm dev @@ -211,7 +224,7 @@ the deposit/withdraw forms. | `--evm-private-key KEY` | Anvil account 0 | **required** | Private key for EVM txs | | `--evm-chain-id ID` | 31337 | 31337 | EVM chain ID | | `--light-client-address ADDR` | read from `/shared/` | deployed if omitted | Skip LightClient deploy | -| `--bridge-chain-id ID` | polled from relay | **required** | Linera bridge chain (64 hex chars) | +| `--linera-bridge-chain-id ID` | polled from relay | **required** | Linera bridge chain (64 hex chars) | | `--token-address ADDR` | deployed | deployed | Skip MockERC20 deploy | | `--relay-owner OWNER` | read from `/shared/` | **required** | Relay's AccountOwner (minter) | | `--faucet-url URL` | `http://localhost:8080` | `http://localhost:8080` | Linera faucet | @@ -222,12 +235,15 @@ the deposit/withdraw forms. | `--wasm-dir PATH` | `/wasm` | `../../examples/target/wasm32-unknown-unknown/release` | Directory with `.wasm` binaries | | `--contracts-dir PATH` | `/contracts` | `../../linera-bridge/src/solidity` | Solidity source root | | `--output PATH` | `.env.local` | `.env.local` | Output env file | +| `--linera-wallet PATH` | -- | auto (temp dir) | Path to existing Linera wallet.json | +| `--linera-keystore PATH` | -- | auto (temp dir) | Path to existing Linera keystore.json | +| `--linera-storage CONFIG` | -- | auto (temp dir) | Linera storage config (e.g. `rocksdb:path/to/db`) | ## How a deposit works 1. User approves ERC-20 spend on `FungibleBridge` 2. User calls `FungibleBridge.deposit(chainId, appId, owner, amount)` -3. Frontend POSTs the tx hash to the relay's `/deposit` endpoint +3. Relay's EVM scanner detects the `DepositInitiated` event 4. Relay generates a receipt inclusion proof (MPT) and submits it to the `evm-bridge` Linera app 5. `evm-bridge` verifies the proof and tells `wrapped-fungible` to mint tokens @@ -243,6 +259,40 @@ the deposit/withdraw forms. 4. `FungibleBridge` verifies the block via `LightClient`, deserializes the `Credit`, and transfers ERC-20 tokens to the user's EVM address +## Active scanning and auto-retry + +The relay actively scans both chains to detect missed or failed bridging +requests and automatically retries them: + +- **EVM→Linera deposits**: the relay polls EVM for `DepositInitiated` events + and checks the Linera `evm-bridge` app to see if each deposit has been + processed. Unprocessed deposits are retried by regenerating the MPT proof + and resubmitting. +- **Linera→EVM burns**: the relay scans Linera blocks for Credit messages to + EVM addresses and checks EVM for matching ERC-20 `Transfer` events. + Unforwarded burns are retried by re-reading the burn execution block from + chain storage and re-calling `addBlock`. + +This means the relay self-heals after crashes or transient RPC failures +without operator intervention. On-chain replay protection (`processed_deposits` +on Linera, `verifiedBlocks` on EVM) makes retries safe. + +Monitoring endpoints: + +| Endpoint | Description | +|----------|-------------| +| `GET /monitor/status` | Summary counts of pending/completed deposits and burns | +| `GET /monitor/deposits?status=pending` | List pending deposits | +| `GET /monitor/burns?status=pending` | List unforwarded burns | + +Relay flags for tuning: + +| Flag | Default | Description | +|------|---------|-------------| +| `--monitor-scan-interval` | 30 | Seconds between chain scan iterations | +| `--monitor-start-block` | 0 | EVM block to start scanning from | +| `--max-retries` | 10 | Max retry attempts before marking an item as failed | + ## Frontend environment The frontend reads these variables from `.env.local` (generated by `setup.sh`): @@ -260,6 +310,25 @@ LINERA_EVM_CHAIN_ID All are required -- the Vite dev server will refuse to start if any are missing. +## EVM finality verification + +The `evm-bridge` Linera app verifies that deposit blocks are finalized by +querying an EVM JSON-RPC endpoint (the `rpc_endpoint` parameter). This endpoint +hostname must be in the Linera network's HTTP request allow list. + +- **Docker mode**: The compose file passes `--http-request-allow-list` to + `linera net up`, defaulting to `anvil`. Override with the + `HTTP_REQUEST_ALLOW_LIST` env var (e.g. + `HTTP_REQUEST_ALLOW_LIST=base-sepolia.g.alchemy.com`). +- **Testnet mode**: The Linera testnet validators must whitelist the RPC + hostname (e.g. `base-sepolia.g.alchemy.com`). Contact the testnet operators + to add your RPC provider's hostname to the allow list. + +If the RPC hostname is not whitelisted, deposits will fail with +`UnauthorizedHttpRequest`. As a workaround, set `rpc_endpoint` to empty in the +evm-bridge parameters to skip finality verification (not recommended for +production). + ## Troubleshooting - **MetaMask shows 0 ETH** (local): make sure you switched to the Anvil diff --git a/examples/bridge-demo/index.html b/examples/bridge-demo/index.html index 716147c8aaed..3478c9b1df5e 100644 --- a/examples/bridge-demo/index.html +++ b/examples/bridge-demo/index.html @@ -405,7 +405,7 @@

Chain -

const FAUCET_URL = import.meta.env.LINERA_FAUCET_URL; const APP_ID = import.meta.env.LINERA_APPLICATION_ID; // wrapped-fungible const BRIDGE_APP_ID = import.meta.env.LINERA_BRIDGE_APP_ID; // evm-bridge - const RELAY_URL = import.meta.env.LINERA_RELAY_URL; + // RELAY_URL is no longer needed — the relay auto-detects deposits via active scanning. const BRIDGE_ADDRESS = import.meta.env.LINERA_BRIDGE_ADDRESS; // FungibleBridge on EVM const TOKEN_ADDRESS = import.meta.env.LINERA_TOKEN_ADDRESS; // ERC-20 on EVM const BRIDGE_CHAIN_ID = import.meta.env.LINERA_BRIDGE_CHAIN_ID; // relay's chain @@ -547,32 +547,20 @@

Chain -

const ownerHex = lineraOwner.replace(/^0x/, ''); const ownerBytes32 = '0x' + ownerHex.slice(0, 64).padStart(64, '0'); - // Step 1: Approve - setStatus('Step 1/3: Approving ERC-20 spend...'); + // Step 1: Approve + Deposit on EVM + setStatus('Step 1/2: Approving ERC-20 spend...'); const approveTx = await token.approve(BRIDGE_ADDRESS, amountWei); - setStatus('Step 1/3: Waiting for approval confirmation...'); + setStatus('Step 1/2: Waiting for approval confirmation...'); await waitForTx(approveTx.hash); - // Step 2: Deposit on EVM - setStatus('Step 2/3: Depositing to FungibleBridge...'); + setStatus('Step 1/2: Depositing to FungibleBridge...'); const depositTx = await bridge.deposit(chainIdBytes, appIdBytes32, ownerBytes32, amountWei); - setStatus('Step 2/3: Waiting for deposit confirmation...'); + setStatus('Step 1/2: Waiting for deposit confirmation...'); const receipt = await waitForTx(depositTx.hash); const txHash = receipt.hash; - // Step 3: Send to relay (generates proof + submits to Linera) - setStatus('Step 3/3: Relay processing deposit...'); - const depositResp = await fetch(`${RELAY_URL}/deposit`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tx_hash: txHash }), - }); - if (!depositResp.ok) { - const body = await depositResp.json().catch(() => ({})); - throw new Error(body.error || `Relay returned ${depositResp.status}`); - } - - setStatus('Deposit complete! Waiting for balance update...'); + // Step 3: Wait for relay to auto-detect and process the deposit + setStatus('Step 2/2: Waiting for relay to process deposit...'); await updateEvmBalance(); // Poll for balance update (cross-chain Credit may take a few seconds) const depositDeadline = Date.now() + 60_000; @@ -705,6 +693,12 @@

Chain -

} }); + // Periodically refresh balances to catch relay-driven changes. + setInterval(async () => { + await updateEvmBalance(); + await updateLineraBalance(); + }, 5_000); + setStatus('Ready. Connect MetaMask to begin.'); } diff --git a/examples/bridge-demo/setup.sh b/examples/bridge-demo/setup.sh index 780e9ba97779..a2bd52139752 100755 --- a/examples/bridge-demo/setup.sh +++ b/examples/bridge-demo/setup.sh @@ -4,7 +4,7 @@ # Two modes: # Docker mode: ./setup.sh --compose-file ../../docker/docker-compose.bridge-test.yml # Direct mode: ./setup.sh --evm-rpc-url URL --evm-private-key KEY \ -# --light-client-address ADDR --bridge-chain-id ID +# --light-client-address ADDR --linera-bridge-chain-id ID # # Docker mode runs forge/cast/linera inside Docker containers (local dev). # Direct mode calls them directly on the host (real network deployments). @@ -38,6 +38,9 @@ EXTRA_WALLET_ID=1 REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" LINERA_BIN="" LINERA_BRIDGE_BIN="" +LINERA_WALLET_PATH="" +LINERA_KEYSTORE_PATH="" +LINERA_STORAGE_PATH="" die() { echo "ERROR: $*" >&2; exit 1; } @@ -67,7 +70,7 @@ Docker mode (local dev): Direct mode (real networks): $(basename "$0") --evm-rpc-url URL --evm-private-key KEY \\ - --bridge-chain-id ID --relay-owner OWNER --faucet-url URL + --linera-bridge-chain-id ID --relay-owner OWNER --faucet-url URL Options: --compose-file PATH Docker Compose file (enables Docker mode) @@ -80,7 +83,7 @@ Options: --light-client-address ADDR LightClient contract address (skip deploy) Docker mode reads from /shared/ Direct mode deploys if not provided - --bridge-chain-id ID Linera bridge chain ID (64 hex chars) + --linera-bridge-chain-id ID Linera bridge chain ID (64 hex chars) Docker mode polls /shared/ --token-address ADDR ERC20 token address (skip MockERC20 deploy) --wasm-dir PATH Directory with .wasm binaries @@ -98,8 +101,10 @@ Options: --fund-amount WEI Fund bridge with this many tokens; 0 to skip (default: 500000000000000000000) --shared-dir PATH Directory for shared state files (bridge-address, - app IDs). Relay polls these files. - Default: /tmp/bridge-demo- + app IDs). Default: /tmp/bridge-demo- + --linera-wallet PATH Path to existing Linera wallet.json + --linera-keystore PATH Path to existing Linera keystore.json + --linera-storage CONFIG Linera storage config (e.g. rocksdb:/path/to/client.db) --help Show this help EOF exit 0 @@ -113,7 +118,7 @@ while [[ $# -gt 0 ]]; do --evm-private-key) EVM_PRIVATE_KEY="$2"; shift 2 ;; --evm-chain-id) EVM_CHAIN_ID="$2"; shift 2 ;; --light-client-address) LIGHT_CLIENT_ADDR="$2"; shift 2 ;; - --bridge-chain-id) BRIDGE_CHAIN_ID="$2"; shift 2 ;; + --linera-bridge-chain-id) BRIDGE_CHAIN_ID="$2"; shift 2 ;; --token-address) TOKEN_ADDRESS="$2"; shift 2 ;; --wasm-dir) WASM_DIR="$2"; shift 2 ;; --contracts-dir) CONTRACTS_DIR="$2"; shift 2 ;; @@ -124,6 +129,9 @@ while [[ $# -gt 0 ]]; do --ticker-symbol) TICKER_SYMBOL="$2"; shift 2 ;; --fund-amount) FUND_AMOUNT="$2"; shift 2 ;; --shared-dir) SHARED_DIR="$2"; shift 2 ;; + --linera-wallet) LINERA_WALLET_PATH="$2"; shift 2 ;; + --linera-keystore) LINERA_KEYSTORE_PATH="$2"; shift 2 ;; + --linera-storage) LINERA_STORAGE_PATH="$2"; shift 2 ;; --help) usage ;; *) die "Unknown option: $1" ;; esac @@ -158,9 +166,9 @@ linera_exec() { LINERA_STORAGE="rocksdb:$WALLET_DIR/client_${EXTRA_WALLET_ID}.db" \ ./linera "$@" else - LINERA_WALLET="$LINERA_TMP_DIR/wallet.json" \ - LINERA_KEYSTORE="$LINERA_TMP_DIR/keystore.json" \ - LINERA_STORAGE="rocksdb:$LINERA_TMP_DIR/client.db" \ + LINERA_WALLET="$LINERA_WALLET_PATH" \ + LINERA_KEYSTORE="$LINERA_KEYSTORE_PATH" \ + LINERA_STORAGE="$LINERA_STORAGE_PATH" \ "$LINERA_BIN" "$@" fi } @@ -184,7 +192,7 @@ wait_for_tx() { fi echo " Waiting for tx $tx_hash..." evm_exec cast receipt --confirmations 1 \ - --rpc-url "$EVM_RPC_URL" "$tx_hash" >/dev/null 2>&1 || true + --rpc-url "$EVM_RPC_URL" "$tx_hash" >/dev/null } # ── Mode detection & defaults ── @@ -202,7 +210,7 @@ if [[ -n "$COMPOSE_FILE" ]]; then else echo "Mode: Direct" [[ -z "$EVM_PRIVATE_KEY" ]] && die "--evm-private-key is required in direct mode" - [[ -z "$BRIDGE_CHAIN_ID" ]] && die "--bridge-chain-id is required in direct mode" + [[ -z "$BRIDGE_CHAIN_ID" ]] && die "--linera-bridge-chain-id is required in direct mode" EVM_RPC_URL="${EVM_RPC_URL:-http://localhost:8545}" WASM_DIR="${WASM_DIR:-$SCRIPT_DIR/../../examples/target/wasm32-unknown-unknown/release}" CONTRACTS_DIR="${CONTRACTS_DIR:-$SCRIPT_DIR/../../linera-bridge/src/solidity}" @@ -245,20 +253,48 @@ echo " Shared dir: $SHARED_DIR" echo "=== Bridge Demo Setup ===" -# ── 0. Initialize Linera wallet (direct mode only) ── +# ── 0. Resolve Linera wallet paths (direct mode only) ── +# Resolve env var fallbacks early so we can print them. if [[ -z "$COMPOSE_FILE" ]]; then - LINERA_TMP_DIR="$SHARED_DIR/linera-wallet" - mkdir -p "$LINERA_TMP_DIR" - if [[ ! -f "$LINERA_TMP_DIR/wallet.json" ]]; then - echo "Initializing Linera wallet from faucet..." - linera_exec wallet init --faucet "$FAUCET_URL" - echo "Requesting a chain from faucet..." - linera_exec wallet request-chain --faucet "$FAUCET_URL" + LINERA_WALLET_PATH="${LINERA_WALLET_PATH:-${LINERA_WALLET:-}}" + LINERA_KEYSTORE_PATH="${LINERA_KEYSTORE_PATH:-${LINERA_KEYSTORE:-}}" + LINERA_STORAGE_PATH="${LINERA_STORAGE_PATH:-${LINERA_STORAGE:-}}" + if [[ -n "$LINERA_WALLET_PATH" ]]; then + # Use caller-provided wallet paths. + [[ -z "$LINERA_KEYSTORE_PATH" ]] && die "--linera-keystore is required when --linera-wallet is set" + [[ -z "$LINERA_STORAGE_PATH" ]] && die "--linera-storage is required when --linera-wallet is set" + echo " Using wallet at $LINERA_WALLET_PATH" else - echo " Using existing wallet at $LINERA_TMP_DIR" + # Create a temporary wallet. + LINERA_TMP_DIR="$SHARED_DIR/linera-wallet" + mkdir -p "$LINERA_TMP_DIR" + LINERA_WALLET_PATH="$LINERA_TMP_DIR/wallet.json" + LINERA_KEYSTORE_PATH="$LINERA_TMP_DIR/keystore.json" + LINERA_STORAGE_PATH="rocksdb:$LINERA_TMP_DIR/client.db" + if [[ ! -f "$LINERA_WALLET_PATH" ]]; then + echo "Initializing Linera wallet from faucet..." + linera_exec wallet init --faucet "$FAUCET_URL" + else + echo " Using existing wallet at $LINERA_TMP_DIR" + fi fi fi +echo "" +echo "Configuration:" +echo " EVM RPC URL: $EVM_RPC_URL" +echo " EVM chain ID: $EVM_CHAIN_ID" +echo " Bridge chain ID: ${BRIDGE_CHAIN_ID:-(will be read from /shared/)}" +echo " Relay owner: ${RELAY_OWNER:-(will be read from /shared/)}" +echo " Faucet URL: $FAUCET_URL" +echo " Shared dir: $SHARED_DIR" +if [[ -z "$COMPOSE_FILE" ]]; then +echo " Linera wallet: $LINERA_WALLET_PATH" +echo " Linera keystore: $LINERA_KEYSTORE_PATH" +echo " Linera storage: $LINERA_STORAGE_PATH" +fi +echo "" + # ── 1. Deploy or read LightClient ── if [[ -n "$COMPOSE_FILE" && -z "$LIGHT_CLIENT_ADDR" ]]; then echo "Reading LightClient address from /shared/..." @@ -301,9 +337,9 @@ echo " LightClient: $LIGHT_CLIENT_ADDR" # ── 2. Read bridge chain ID (Docker mode only) ── if [[ -n "$COMPOSE_FILE" && -z "$BRIDGE_CHAIN_ID" ]]; then - echo "Waiting for relay to claim bridge chain..." + echo "Waiting for bridge chain ID..." for i in $(seq 1 30); do - BRIDGE_CHAIN_ID=$(dc_exec linera-relay cat /shared/bridge-chain-id 2>/dev/null | tr -d '[:space:]' || true) + BRIDGE_CHAIN_ID=$(dc_exec foundry-tools cat /shared/bridge-chain-id 2>/dev/null | tr -d '[:space:]') BRIDGE_CHAIN_ID=$(normalize_hex "$BRIDGE_CHAIN_ID") if echo "$BRIDGE_CHAIN_ID" | grep -qE '^[a-f0-9]{64}$'; then break @@ -313,7 +349,7 @@ if [[ -n "$COMPOSE_FILE" && -z "$BRIDGE_CHAIN_ID" ]]; then sleep 2 done if [[ -z "$BRIDGE_CHAIN_ID" ]]; then - die "Relay did not write bridge chain ID within timeout" + die "Bridge chain ID not found within timeout" fi else BRIDGE_CHAIN_ID=$(normalize_hex "$BRIDGE_CHAIN_ID") @@ -355,14 +391,14 @@ TOKEN_ADDR_HEX=$(echo "$TOKEN_ADDRESS" | sed 's/^0x//') echo "Reading relay owner..." if [[ -n "$COMPOSE_FILE" ]]; then for i in $(seq 1 30); do - RELAY_OWNER=$(dc_exec linera-relay cat /shared/relay-owner 2>/dev/null | tr -d '[:space:]' || true) + RELAY_OWNER=$(dc_exec foundry-tools cat /shared/relay-owner 2>/dev/null | tr -d '[:space:]') if [[ -n "$RELAY_OWNER" ]]; then break fi echo " Waiting for relay owner... ($i/30)" sleep 2 done - [[ -z "$RELAY_OWNER" ]] && die "Relay did not write owner within timeout" + [[ -z "$RELAY_OWNER" ]] && die "Relay owner not found within timeout" else [[ -z "$RELAY_OWNER" ]] && die "--relay-owner is required in direct mode" fi @@ -370,8 +406,8 @@ echo " Relay owner (minter): $RELAY_OWNER" # ── 4. Publish and create wrapped-fungible app ── echo "Syncing chain state..." -linera_exec sync 2>&1 || true -linera_exec process-inbox 2>&1 || true +linera_exec sync 2>&1 +linera_exec process-inbox 2>&1 echo "Publishing and creating wrapped-fungible app..." WRAPPED_PARAMS=$( TICKER="$TICKER_SYMBOL" \ @@ -392,15 +428,16 @@ params = { } print(json.dumps(params)) ") -WRAPPED_APP_OUTPUT=$(linera_exec publish-and-create \ - "$WASM_DIR/wrapped_fungible_contract.wasm" \ - "$WASM_DIR/wrapped_fungible_service.wasm" \ - --json-parameters "$WRAPPED_PARAMS" \ - --json-argument '{"accounts":{}}' 2>&1) || { - echo "ERROR: publish-and-create wrapped-fungible failed:" >&2 - echo "$WRAPPED_APP_OUTPUT" >&2 - exit 1 -} +for attempt in 1 2 3; do + WRAPPED_APP_OUTPUT=$(linera_exec publish-and-create \ + "$WASM_DIR/wrapped_fungible_contract.wasm" \ + "$WASM_DIR/wrapped_fungible_service.wasm" \ + --json-parameters "$WRAPPED_PARAMS" \ + --json-argument '{"accounts":{}}' 2>&1) && break + echo " Attempt $attempt failed, retrying..." >&2 + sleep 2 +done +[[ -z "$WRAPPED_APP_OUTPUT" ]] && { echo "ERROR: publish-and-create wrapped-fungible failed after retries" >&2; exit 1; } # The application ID is the last 64-hex-char token on its own line. WRAPPED_APP_ID=$(echo "$WRAPPED_APP_OUTPUT" | grep -oE '^[a-f0-9]{64}$' | tail -1) validate_hex64 "Wrapped-fungible app ID" "$WRAPPED_APP_ID" @@ -429,7 +466,6 @@ BRIDGE_OUTPUT=$(evm_exec \ --constructor-args \ "$LIGHT_CLIENT_ADDR" \ "$CHAIN_BYTES32" \ - 0 \ "$APP_ID_BYTES32" \ "$TOKEN_ADDRESS") BRIDGE_ADDRESS=$(echo "$BRIDGE_OUTPUT" | parse_address) @@ -446,14 +482,15 @@ echo "$BRIDGE_ADDRESS" > "$SHARED_DIR/bridge-address" # ── 6. Publish and create evm-bridge app ── echo "Syncing chain state before evm-bridge deploy..." -linera_exec sync 2>&1 || true -linera_exec process-inbox 2>&1 || true +linera_exec sync 2>&1 +linera_exec process-inbox 2>&1 echo "Publishing and creating evm-bridge app..." BRIDGE_PARAMS=$( CHAIN_ID="$EVM_CHAIN_ID" \ BRIDGE_HEX="$BRIDGE_ADDR_HEX" \ APP_ID="$WRAPPED_APP_ID" \ TOKEN_HEX="$TOKEN_ADDR_HEX" \ + EVM_RPC_URL="$EVM_RPC_URL" \ python3 -c " import json, os def hex_to_array(h): @@ -463,23 +500,25 @@ params = { 'bridge_contract_address': hex_to_array(os.environ['BRIDGE_HEX']), 'fungible_app_id': os.environ['APP_ID'], 'token_address': hex_to_array(os.environ['TOKEN_HEX']), + 'rpc_endpoint': os.environ.get('EVM_RPC_URL', ''), } print(json.dumps(params)) ") -BRIDGE_APP_OUTPUT=$(linera_exec publish-and-create \ - "$WASM_DIR/evm_bridge_contract.wasm" \ - "$WASM_DIR/evm_bridge_service.wasm" \ - --json-parameters "$BRIDGE_PARAMS" \ - --json-argument 'null' \ - --required-application-ids "$WRAPPED_APP_ID" 2>&1) || { - echo "ERROR: publish-and-create evm-bridge failed:" >&2 - echo "$BRIDGE_APP_OUTPUT" >&2 - exit 1 -} +for attempt in 1 2 3; do + BRIDGE_APP_OUTPUT=$(linera_exec publish-and-create \ + "$WASM_DIR/evm_bridge_contract.wasm" \ + "$WASM_DIR/evm_bridge_service.wasm" \ + --json-parameters "$BRIDGE_PARAMS" \ + --json-argument 'null' \ + --required-application-ids "$WRAPPED_APP_ID" 2>&1) && break + echo " Attempt $attempt failed, retrying..." >&2 + sleep 2 +done +[[ -z "$BRIDGE_APP_OUTPUT" ]] && { echo "ERROR: publish-and-create evm-bridge failed after retries" >&2; exit 1; } BRIDGE_APP_ID=$(echo "$BRIDGE_APP_OUTPUT" | grep -oE '^[a-f0-9]{64}$' | tail -1) -validate_hex64 "EVM-bridge app ID" "$BRIDGE_APP_ID" -echo " EVM-bridge app: $BRIDGE_APP_ID" +validate_hex64 "Linera bridge app ID" "$BRIDGE_APP_ID" +echo " Linera bridge app: $BRIDGE_APP_ID" # Write bridge app ID to shared dir for relay. if [[ -n "$COMPOSE_FILE" ]]; then @@ -514,16 +553,47 @@ LINERA_BRIDGE_CHAIN_ID=$BRIDGE_CHAIN_ID LINERA_EVM_CHAIN_ID=$EVM_CHAIN_ID EOF +# Write setup-complete marker so the relay knows it's safe to start. +if [[ -n "$COMPOSE_FILE" ]]; then + dc_exec --user root foundry-tools sh -c "echo 'done' > /shared/setup-complete" +fi + echo "" echo "=== Setup Complete ===" echo "" -echo "Environment written to: $OUTPUT_FILE" -echo "Shared state dir: $SHARED_DIR" +echo "Addresses & IDs:" +echo " LightClient (EVM): $LIGHT_CLIENT_ADDR" +echo " FungibleBridge (EVM): $BRIDGE_ADDRESS" +echo " MockERC20 (EVM): $TOKEN_ADDRESS" +echo " evm-bridge (Linera): $BRIDGE_APP_ID" +echo " wrapped-fungible (Linera): $WRAPPED_APP_ID" +echo " Bridge chain ID: $BRIDGE_CHAIN_ID" +echo " Relay owner: $RELAY_OWNER" +echo " EVM chain ID: $EVM_CHAIN_ID" echo "" -echo "The relay should already be running (it provides --bridge-chain-id and" -echo "--relay-owner that this script requires). It will pick up the app IDs" -echo "from the shared dir automatically." +echo "Environment written to: $OUTPUT_FILE" +echo "Shared state dir: $SHARED_DIR" echo "" -echo "Start the frontend:" -echo " cd examples/bridge-demo" -echo " pnpm install && pnpm dev" +echo "Next steps:" +if [[ -n "$COMPOSE_FILE" ]]; then +echo " 1. The relay is running via docker-compose (linera-relay service)." +else +echo " 1. Start the relay:" +echo " linera-bridge serve \\" +echo " --rpc-url $EVM_RPC_URL \\" +echo " --faucet-url $FAUCET_URL \\" +echo " --wallet $LINERA_WALLET_PATH \\" +echo " --keystore $LINERA_KEYSTORE_PATH \\" +echo " --storage $LINERA_STORAGE_PATH \\" +echo " --linera-bridge-chain-id $BRIDGE_CHAIN_ID \\" +echo " --linera-bridge-chain-owner $RELAY_OWNER \\" +echo " --evm-bridge-address $BRIDGE_ADDRESS \\" +echo " --linera-bridge-address $BRIDGE_APP_ID \\" +echo " --linera-fungible-address $WRAPPED_APP_ID \\" +echo " --evm-private-key 0x... \\" +echo " --monitor-scan-interval 30 \\" +echo " --max-retries 10" +fi +echo " 2. Start the frontend:" +echo " cd examples/bridge-demo" +echo " pnpm install && pnpm dev" diff --git a/examples/evm-bridge/Cargo.toml b/examples/evm-bridge/Cargo.toml index d8f6e9774868..52750fceaf15 100644 --- a/examples/evm-bridge/Cargo.toml +++ b/examples/evm-bridge/Cargo.toml @@ -15,7 +15,8 @@ hex.workspace = true linera-bridge = { path = "../../linera-bridge", default-features = false, features = [ "chain", ] } -linera-sdk.workspace = true +linera-sdk = { workspace = true, features = ["ethereum"] } +log.workspace = true serde.workspace = true wrapped-fungible.workspace = true @@ -27,7 +28,9 @@ linera-bridge = { path = "../../linera-bridge", default-features = false, featur "chain", "testing", ] } +linera-ethereum.workspace = true linera-sdk = { workspace = true, features = ["test", "wasmer"] } +serde.workspace = true tokio.workspace = true wrapped-fungible.workspace = true diff --git a/examples/evm-bridge/src/contract.rs b/examples/evm-bridge/src/contract.rs index 5e7cd76905a8..69e9c1b04f75 100644 --- a/examples/evm-bridge/src/contract.rs +++ b/examples/evm-bridge/src/contract.rs @@ -3,21 +3,23 @@ #![cfg_attr(target_arch = "wasm32", no_main)] -use alloy_primitives::Bytes; +use alloy_primitives::{Bytes, B256}; use evm_bridge::{BridgeOperation, BridgeParameters, DepositKey, EvmBridgeAbi}; use linera_bridge::proof; use linera_sdk::{ + ethereum::{ContractEthereumClient, EthereumQueries}, linera_base_types::{Account, AccountOwner, Amount, ChainId, WithContractAbi}, views::{linera_views, RootView, SetView, View, ViewStorageContext}, Contract, ContractRuntime, }; use wrapped_fungible::{WrappedFungibleOperation, WrappedFungibleTokenAbi}; -/// On-chain state: tracks processed deposits for replay protection. +/// On-chain state: tracks processed deposits and verified block hashes. #[derive(RootView)] #[view(context = ViewStorageContext)] pub struct BridgeState { - pub processed_deposits: SetView, + pub processed_deposits: SetView<[u8; 32]>, + pub verified_block_hashes: SetView<[u8; 32]>, } pub struct EvmBridgeContract { @@ -45,8 +47,19 @@ impl Contract for EvmBridgeContract { } async fn instantiate(&mut self, _argument: ()) { - // Validate parameters are present. - self.runtime.application_parameters(); + let params = self.runtime.application_parameters(); + if !params.rpc_endpoint.is_empty() { + let client = ContractEthereumClient::new(params.rpc_endpoint.clone()); + let chain_id = client + .get_chain_id() + .await + .expect("failed to query chain ID from RPC endpoint"); + assert_eq!( + chain_id, params.source_chain_id, + "RPC endpoint chain ID {chain_id} does not match configured source_chain_id {}", + params.source_chain_id + ); + } } async fn execute_operation(&mut self, operation: BridgeOperation) { @@ -67,6 +80,18 @@ impl Contract for EvmBridgeContract { ) .await; } + BridgeOperation::VerifyBlockHash { block_hash } => { + self.verify_block_hash(block_hash).await; + + // Only cache when called by an authenticated signer (chain owner), + // preventing unauthenticated callers from bloating state. + if self.runtime.authenticated_owner().is_some() { + self.state + .verified_block_hashes + .insert(&block_hash) + .expect("failed to insert verified block hash"); + } + } } } @@ -78,6 +103,28 @@ impl Contract for EvmBridgeContract { } impl EvmBridgeContract { + async fn verify_block_hash(&mut self, block_hash: [u8; 32]) { + let params = self.runtime.application_parameters(); + assert!( + !params.rpc_endpoint.is_empty(), + "rpc_endpoint must be configured to verify block hashes" + ); + + let client = ContractEthereumClient::new(params.rpc_endpoint.clone()); + assert!( + client + .is_block_hash_finalized(B256::from(block_hash)) + .await + .expect("failed to check block finality — block may not exist"), + "block is not finalized" + ); + + log::info!( + "verified block hash {} is finalized", + hex::encode(block_hash) + ); + } + async fn process_deposit( &mut self, block_header_rlp: &[u8], @@ -92,6 +139,21 @@ impl EvmBridgeContract { let (block_hash, receipts_root) = proof::decode_block_header(block_header_rlp).expect("invalid block header RLP"); + // 1b. Finality check: when an endpoint is configured, verify the block hash + // is finalized. Uses cached result if a previous deposit from this block + // was already processed. + if params.rpc_endpoint.is_empty() { + log::warn!("rpc_endpoint is empty — skipping block finality verification."); + } else if !self + .state + .verified_block_hashes + .contains(&block_hash.0) + .await + .expect("failed to check verified block hashes") + { + self.verify_block_hash(block_hash.0).await; + } + // 2. Verify receipt inclusion via MPT proof let proof_bytes: Vec = proof_nodes .iter() @@ -131,19 +193,29 @@ impl EvmBridgeContract { tx_index, log_index, }; + let deposit_hash = deposit_key.hash(); assert!( !self .state .processed_deposits - .contains(&deposit_key) + .contains(&deposit_hash) .await .expect("failed to check processed deposits"), "deposit already processed" ); self.state .processed_deposits - .insert(&deposit_key) - .expect("failed to insert deposit key"); + .insert(&deposit_hash) + .expect("failed to insert deposit hash"); + + // 5b. Cache the verified block hash so subsequent deposits from the same + // block skip the RPC finality check. + if !params.rpc_endpoint.is_empty() { + self.state + .verified_block_hashes + .insert(&block_hash.0) + .expect("failed to cache verified block hash"); + } // 6. Convert deposit fields to Linera types and call Mint let target_chain_id = diff --git a/examples/evm-bridge/src/lib.rs b/examples/evm-bridge/src/lib.rs index fa63547c7ba3..05396a89fa1c 100644 --- a/examples/evm-bridge/src/lib.rs +++ b/examples/evm-bridge/src/lib.rs @@ -7,6 +7,7 @@ //! on EVM and mints wrapped tokens on Linera via the wrapped-fungible app. use async_graphql::{Request, Response}; +pub use linera_bridge::proof::DepositKey; use linera_sdk::linera_base_types::{ApplicationId, ContractAbi, ServiceAbi}; use serde::{Deserialize, Serialize}; @@ -21,15 +22,10 @@ pub struct BridgeParameters { pub fungible_app_id: ApplicationId, /// ERC-20 token address on the source EVM chain. pub token_address: [u8; 20], -} - -/// Replay-protection key for processed deposits. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -pub struct DepositKey { - pub source_chain_id: u64, - pub block_hash: [u8; 32], - pub tx_index: u64, - pub log_index: u64, + /// JSON-RPC endpoint of the source EVM chain for finality verification. + /// When non-empty, `ProcessDeposit` requires the block hash to be verified first + /// via `VerifyBlockHash`. + pub rpc_endpoint: String, } /// Operations accepted by the bridge contract. @@ -43,6 +39,12 @@ pub enum BridgeOperation { tx_index: u64, log_index: u64, }, + /// Verify that an EVM block hash is authentic and finalized. + /// + /// Queries the EVM node to confirm the block exists and its number is at or below + /// the latest finalized block. Caches the hash only when submitted by an + /// authenticated signer (chain owner) to prevent state bloat. + VerifyBlockHash { block_hash: [u8; 32] }, } pub struct EvmBridgeAbi; diff --git a/examples/evm-bridge/src/service.rs b/examples/evm-bridge/src/service.rs index 7571a861cf41..23806b256bfc 100644 --- a/examples/evm-bridge/src/service.rs +++ b/examples/evm-bridge/src/service.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use async_graphql::{EmptyMutation, EmptySubscription, Object, Request, Response, Schema}; -use evm_bridge::{BridgeParameters, DepositKey, EvmBridgeAbi}; +use evm_bridge::{BridgeParameters, EvmBridgeAbi}; use linera_sdk::{ linera_base_types::WithServiceAbi, views::{linera_views, RootView, SetView, View, ViewStorageContext}, @@ -17,7 +17,7 @@ use linera_sdk::{ #[derive(RootView)] #[view(context = ViewStorageContext)] pub struct BridgeState { - pub processed_deposits: SetView, + pub processed_deposits: SetView<[u8; 32]>, } #[derive(Clone)] @@ -71,4 +71,20 @@ impl EvmBridgeService { let params: BridgeParameters = self.runtime.application_parameters(); format!("0x{}", hex::encode(params.token_address)) } + + /// Whether a deposit with the given hash has been processed. + /// + /// The hash is the hex-encoded keccak-256 of the deposit key + /// (see [`evm_bridge::DepositKey::hash`]). + async fn is_deposit_processed(&self, hash: String) -> bool { + let bytes: [u8; 32] = hex::decode(hash.strip_prefix("0x").unwrap_or(&hash)) + .expect("invalid hex") + .try_into() + .expect("hash must be 32 bytes"); + self.state + .processed_deposits + .contains(&bytes) + .await + .expect("failed to check processed deposits") + } } diff --git a/examples/evm-bridge/tests/process_deposit.rs b/examples/evm-bridge/tests/process_deposit.rs index 9c7a62266c4e..12f67006c441 100644 --- a/examples/evm-bridge/tests/process_deposit.rs +++ b/examples/evm-bridge/tests/process_deposit.rs @@ -19,6 +19,7 @@ use linera_sdk::{ linera_base_types::{AccountOwner, Amount, ApplicationId}, test::{ActiveChain, TestValidator}, }; +use serde::Deserialize; use wrapped_fungible::{WrappedFungibleTokenAbi, WrappedParameters}; /// Helper to query an account balance on the wrapped-fungible app. @@ -93,6 +94,7 @@ impl TestBridge { bridge_contract_address: [0xBB; 20], fungible_app_id: fungible_app_id.forget_abi(), token_address, + rpc_endpoint: String::new(), }; let bridge_app_id = chain .create_application(bridge_module_id, bridge_params, (), vec![]) @@ -165,7 +167,7 @@ impl TestBridge { let (receipts_root, proof_bytes) = build_receipt_trie(&[(tx_index, receipt.clone())], tx_index); let proof_nodes: Vec> = proof_bytes.into_iter().map(|b| b.to_vec()).collect(); - let block_header = build_test_header(receipts_root); + let block_header = build_test_header(receipts_root, 12345); (block_header, receipt, proof_nodes, tx_index, 0) } } @@ -219,7 +221,7 @@ async fn test_process_deposit() { assert!(result.is_err(), "replay deposit should be rejected"); // Use a different (wrong) receipts root in the block header - let wrong_header = build_test_header(B256::from([0xFF; 32])); + let wrong_header = build_test_header(B256::from([0xFF; 32]), 12345); let result = tb .chain @@ -556,3 +558,198 @@ async fn test_replay_different_log_index_succeeds() { Some(Amount::from_attos(2_000_000u128)), ); } + +// -- finality verification tests -- + +/// When `rpc_endpoint` is set but the RPC endpoint is unreachable, +/// instantiation should fail because the chain ID check cannot succeed. +#[tokio::test] +async fn test_instantiation_fails_with_unreachable_endpoint() { + let (validator, bridge_module_id) = + TestValidator::with_current_module::().await; + let mut chain = validator.new_chain().await; + let chain_owner = AccountOwner::from(chain.public_key()); + + let fungible_module_id = chain + .publish_bytecode_files_in::( + "../wrapped-fungible", + ) + .await; + + let token_address = [0xA0; 20]; + let source_chain_id = 8453u64; + + let wrapped_params = WrappedParameters { + ticker_symbol: "wUSDC".to_string(), + minter: chain_owner, + mint_chain_id: chain.id(), + evm_token_address: token_address, + evm_source_chain_id: source_chain_id, + }; + let fungible_app_id = chain + .create_application( + fungible_module_id, + wrapped_params, + InitialStateBuilder::default().build(), + vec![], + ) + .await; + + // Non-empty endpoint that is unreachable → instantiation should fail + let bridge_params = BridgeParameters { + source_chain_id, + bridge_contract_address: [0xBB; 20], + fungible_app_id: fungible_app_id.forget_abi(), + token_address, + rpc_endpoint: "http://localhost:8545".to_string(), + }; + let result = chain + .try_create_application(bridge_module_id, bridge_params, (), vec![]) + .await; + + assert!( + result.is_err(), + "instantiation should fail with unreachable endpoint" + ); +} + +// -- Anvil-based finality verification tests -- +// These require `anvil` (from Foundry) to be installed. + +/// Minimal block response for extracting hash from an Anvil RPC call. +#[derive(Deserialize)] +struct EthBlock { + hash: B256, +} + +/// Queries Anvil for the latest block hash (outside the contract, for test setup). +async fn get_anvil_block_hash(endpoint: &str) -> B256 { + use linera_ethereum::client::JsonRpcClient; + let rpc = linera_ethereum::provider::EthereumClientSimplified::new(endpoint.to_string()); + let block: EthBlock = rpc + .request("eth_getBlockByNumber", ("latest", false)) + .await + .expect("failed to query Anvil for latest block"); + block.hash +} + +/// Sets up a bridge instance with Anvil as the EVM endpoint and the TestValidator +/// configured to allow HTTP requests to Anvil's host. +async fn setup_bridge_with_anvil( + anvil_endpoint: &str, +) -> ( + ActiveChain, + AccountOwner, + ApplicationId, + ApplicationId, +) { + let (mut validator, bridge_module_id) = + TestValidator::with_current_module::().await; + + // Allow the contract to make HTTP requests to the Anvil host. + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + let mut chain = validator.new_chain().await; + let chain_owner = AccountOwner::from(chain.public_key()); + + let fungible_module_id = chain + .publish_bytecode_files_in::( + "../wrapped-fungible", + ) + .await; + + let token_address = [0xA0; 20]; + // Anvil's default chain ID is 31337. + let source_chain_id = 31337u64; + + let wrapped_params = WrappedParameters { + ticker_symbol: "wUSDC".to_string(), + minter: chain_owner, + mint_chain_id: chain.id(), + evm_token_address: token_address, + evm_source_chain_id: source_chain_id, + }; + let fungible_app_id = chain + .create_application( + fungible_module_id, + wrapped_params, + InitialStateBuilder::default().build(), + vec![], + ) + .await; + + let bridge_params = BridgeParameters { + source_chain_id, + bridge_contract_address: [0xBB; 20], + fungible_app_id: fungible_app_id.forget_abi(), + token_address, + rpc_endpoint: anvil_endpoint.to_string(), + }; + let bridge_app_id = chain + .create_application(bridge_module_id, bridge_params, (), vec![]) + .await; + + (chain, chain_owner, bridge_app_id, fungible_app_id) +} + +/// VerifyBlockHash with a real finalized Anvil block hash should succeed. +#[tokio::test] +#[ignore] // requires `anvil` from Foundry +async fn test_verify_block_hash_anvil() { + let anvil = linera_ethereum::test_utils::get_anvil() + .await + .expect("failed to start anvil"); + + let block_hash = get_anvil_block_hash(&anvil.endpoint).await; + + let (chain, _chain_owner, bridge_app_id, _fungible_app_id) = + setup_bridge_with_anvil(&anvil.endpoint).await; + + // VerifyBlockHash should succeed — Anvil treats all blocks as finalized. + chain + .add_block(|block| { + block.with_operation( + bridge_app_id, + &BridgeOperation::VerifyBlockHash { + block_hash: block_hash.0, + }, + ); + }) + .await; +} + +/// VerifyBlockHash with a non-existent block hash should fail. +#[tokio::test] +#[ignore] // requires `anvil` from Foundry +async fn test_verify_block_hash_not_found() { + let anvil = linera_ethereum::test_utils::get_anvil() + .await + .expect("failed to start anvil"); + + let (chain, _chain_owner, bridge_app_id, _fungible_app_id) = + setup_bridge_with_anvil(&anvil.endpoint).await; + + // VerifyBlockHash with a fake hash — Anvil will return null. + let fake_hash = [0xDE; 32]; + let result = chain + .try_add_block(|block| { + block.with_operation( + bridge_app_id, + &BridgeOperation::VerifyBlockHash { + block_hash: fake_hash, + }, + ); + }) + .await; + + assert!( + result.is_err(), + "VerifyBlockHash with non-existent hash should fail" + ); +} diff --git a/linera-bridge/Cargo.toml b/linera-bridge/Cargo.toml index 1e6a020027ec..4a1a8d441ef1 100644 --- a/linera-bridge/Cargo.toml +++ b/linera-bridge/Cargo.toml @@ -22,6 +22,7 @@ offchain = [ "dep:linera-base", "dep:linera-execution", "dep:op-alloy-network", + "dep:thiserror", "dep:tokio", "dep:url", ] @@ -31,28 +32,29 @@ codegen = [ "dep:serde-reflection", "dep:serde_yaml", ] -cli = [ - "offchain", - "dep:clap", - "dep:reqwest", - "dep:serde_json", - "dep:serde", - "dep:fs-err", -] +cli = ["offchain", "dep:clap", "dep:reqwest", "dep:serde_json", "dep:fs-err"] relay = [ "offchain", "cli", "dep:axum", + "dep:dirs", + "dep:fungible", + "dep:wrapped-fungible", "dep:futures", + "dep:prometheus", "dep:hex", "dep:linera-chain", "dep:linera-client", "dep:linera-core", "dep:linera-faucet-client", + "dep:linera-persistent", "dep:linera-storage", "dep:linera-views", + "linera-storage/rocksdb", + "linera-views/rocksdb", "dep:rustls", "dep:tower-http", + "dep:sqlx", "dep:tracing", "dep:tracing-subscriber", ] @@ -66,6 +68,8 @@ alloy-primitives.workspace = true alloy-rlp.workspace = true alloy-trie.workspace = true anyhow.workspace = true +serde.workspace = true +thiserror = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } @@ -88,21 +92,29 @@ url = { workspace = true, optional = true } clap = { workspace = true, optional = true } fs-err = { workspace = true, optional = true } +fungible = { path = "../examples/fungible", optional = true } reqwest = { workspace = true, optional = true } -serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } +wrapped-fungible = { path = "../examples/wrapped-fungible", optional = true } # relay feature deps axum = { workspace = true, optional = true } +dirs = { workspace = true, optional = true } futures = { workspace = true, optional = true } hex = { workspace = true, optional = true } linera-chain = { workspace = true, optional = true } linera-client = { workspace = true, optional = true } -linera-core = { workspace = true, optional = true } +linera-core = { workspace = true, optional = true, features = ["fs"] } linera-faucet-client = { workspace = true, optional = true } +linera-persistent = { workspace = true, optional = true, features = ["fs"] } linera-storage = { workspace = true, optional = true } linera-views = { workspace = true, optional = true } +prometheus = { workspace = true, optional = true } rustls = { version = "0.23", optional = true, features = ["ring"] } +sqlx = { workspace = true, optional = true, features = [ + "runtime-tokio-rustls", + "sqlite", +] } tower-http = { workspace = true, optional = true, features = ["cors"] } tracing = { workspace = true, optional = true } tracing-subscriber = { workspace = true, optional = true, features = ["fmt"] } @@ -147,4 +159,6 @@ alloy = { workspace = true, default-features = false, features = [ "reqwest", ] } alloy-sol-types.workspace = true +sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite"] } +test-case.workspace = true tokio = { workspace = true, features = ["full"] } diff --git a/linera-bridge/Makefile b/linera-bridge/Makefile index 12e49e4e9bf7..cc3c8d568b8a 100644 --- a/linera-bridge/Makefile +++ b/linera-bridge/Makefile @@ -3,7 +3,81 @@ COMPOSE_FILE := $(DOCKER_DIR)/docker-compose.bridge-test.yml DEMO_DIR := ../examples/bridge-demo PROJECT := linera-bridge-demo -.PHONY: build-wasm build-all up down demo demo-setup demo-frontend demo-logs +.PHONY: build-wasm build-all build-relayer relayer-up relayer-down relayer-logs up down demo demo-setup demo-frontend demo-logs + +build-relayer: ## Build the bridge relayer docker image + cd $(DOCKER_DIR) && docker build -f ./Dockerfile.bridge \ + -t $(RELAYER_IMAGE) .. + +RELAYER_CONTAINER := linera-relay +RELAYER_IMAGE ?= linera-bridge + +# Required env vars for relayer-up (must be set by caller) +# RPC_URL, EVM_BRIDGE_ADDRESS, LINERA_BRIDGE_APP, LINERA_FUNGIBLE_APP, EVM_PRIVATE_KEY + +relayer-up: ## Run the bridge relayer container + @missing=""; \ + for var in RPC_URL EVM_BRIDGE_ADDRESS LINERA_BRIDGE_APP LINERA_FUNGIBLE_APP EVM_PRIVATE_KEY; do \ + eval val=\$$$$var; \ + if [ -z "$$val" ]; then missing="$$missing $$var"; fi; \ + done; \ + if [ -n "$$missing" ]; then \ + echo "ERROR: Missing required env vars:$$missing"; \ + echo ""; \ + echo "Usage:"; \ + echo " RPC_URL=... EVM_BRIDGE_ADDRESS=... \\"; \ + echo " LINERA_BRIDGE_APP=... LINERA_FUNGIBLE_APP=... EVM_PRIVATE_KEY=... \\"; \ + echo " make -C linera-bridge relayer-up"; \ + exit 1; \ + fi + @echo "============================================" + @echo " Starting bridge relayer" + @echo "============================================" + @echo "" + @echo "Required:" + @echo " RPC_URL = $(RPC_URL)" + @echo " EVM_BRIDGE_ADDRESS = $(EVM_BRIDGE_ADDRESS)" + @echo " LINERA_BRIDGE_APP = $(LINERA_BRIDGE_APP)" + @echo " LINERA_FUNGIBLE_APP = $(LINERA_FUNGIBLE_APP)" + @echo " EVM_PRIVATE_KEY = $(EVM_PRIVATE_KEY)" + @echo "" + @echo "Optional:" + @echo " FAUCET_URL = $(or $(FAUCET_URL),(not set))" + @echo " PORT = $(or $(PORT),3001)" + @echo " MONITOR_SCAN_INTERVAL= $(or $(MONITOR_SCAN_INTERVAL),30)" + @echo " MONITOR_START_BLOCK = $(or $(MONITOR_START_BLOCK),0)" + @echo " MAX_RETRIES = $(or $(MAX_RETRIES),10)" + @echo " LINERA_BRIDGE_CHAIN_ID = $(or $(LINERA_BRIDGE_CHAIN_ID),(auto-claim))" + @echo " LINERA_WALLET = $(or $(LINERA_WALLET),(default))" + @echo " LINERA_KEYSTORE = $(or $(LINERA_KEYSTORE),(default))" + @echo " LINERA_STORAGE = $(or $(LINERA_STORAGE),(default))" + @echo "============================================" + docker run -d --name $(RELAYER_CONTAINER) \ + -p $(or $(PORT),3001):$(or $(PORT),3001) \ + -e RPC_URL="$(RPC_URL)" \ + -e EVM_BRIDGE_ADDRESS="$(EVM_BRIDGE_ADDRESS)" \ + -e LINERA_BRIDGE_APP="$(LINERA_BRIDGE_APP)" \ + -e LINERA_FUNGIBLE_APP="$(LINERA_FUNGIBLE_APP)" \ + -e EVM_PRIVATE_KEY="$(EVM_PRIVATE_KEY)" \ + $(if $(FAUCET_URL),-e FAUCET_URL="$(FAUCET_URL)") \ + $(if $(PORT),-e PORT="$(PORT)") \ + $(if $(MONITOR_SCAN_INTERVAL),-e MONITOR_SCAN_INTERVAL="$(MONITOR_SCAN_INTERVAL)") \ + $(if $(MONITOR_START_BLOCK),-e MONITOR_START_BLOCK="$(MONITOR_START_BLOCK)") \ + $(if $(MAX_RETRIES),-e MAX_RETRIES="$(MAX_RETRIES)") \ + $(if $(LINERA_BRIDGE_CHAIN_ID),-e LINERA_BRIDGE_CHAIN_ID="$(LINERA_BRIDGE_CHAIN_ID)") \ + $(if $(LINERA_WALLET),-e LINERA_WALLET="$(LINERA_WALLET)") \ + $(if $(LINERA_KEYSTORE),-e LINERA_KEYSTORE="$(LINERA_KEYSTORE)") \ + $(if $(LINERA_STORAGE),-e LINERA_STORAGE="$(LINERA_STORAGE)") \ + $(if $(RUST_LOG),-e RUST_LOG="$(RUST_LOG)") \ + $(RELAYER_IMAGE) + @echo "" + @echo "Relayer started. Logs: make -C linera-bridge relayer-logs" + +relayer-down: ## Stop and remove the bridge relayer container + docker rm -f $(RELAYER_CONTAINER) 2>/dev/null || true + +relayer-logs: ## Tail logs from the standalone relayer container + docker logs -f $(RELAYER_CONTAINER) build-wasm: ## Build all Wasm files needed for bridge demo cd ../examples && cargo build --release --target wasm32-unknown-unknown \ diff --git a/linera-bridge/README.md b/linera-bridge/README.md index c13eff844b4a..4438bb87e484 100644 --- a/linera-bridge/README.md +++ b/linera-bridge/README.md @@ -99,25 +99,25 @@ The constructor takes `(address[], uint64[], bytes32, uint32)` — the genesis c ### Microchain (abstract) -#### `constructor(address _lightClient, bytes32 _chainId, uint64 _latestHeight)` +#### `constructor(address _lightClient, bytes32 _chainId)` -Binds the contract to a specific `LightClient` instance, a Linera chain ID (a 32-byte `CryptoHash`), and an initial block height. +Binds the contract to a specific `LightClient` instance and a Linera chain ID (a 32-byte `CryptoHash`). #### `addBlock(bytes calldata data)` Verifies a certificate via `lightClient.verifyBlock(data)`, then enforces: +- **No duplicate blocks**: rejects certificates already processed via the `verifiedBlocks` mapping. - **Chain ID match**: the block's `header.chain_id` must equal this contract's `chainId`. -- **Sequential heights**: the block's height must equal `nextExpectedHeight`. -On success, calls the virtual `_onBlock(BridgeTypes.Block)` hook. Subcontracts override this to extract and store application-specific data from the verified block. +Blocks can be submitted in any order; sequential height enforcement is not required because BFT-finalized certificates guarantee canonicality. On success, calls the virtual `_onBlock(BridgeTypes.Block)` hook. Subcontracts override this to extract and store application-specific data from the verified block. ### FungibleBridge (concrete Microchain) A `Microchain` subcontract that bridges ERC-20 tokens from Linera to Ethereum. When a fungible `Credit` message targeting an Ethereum address (`Address20`) is received, the contract transfers tokens from its own balance to the recipient. -#### `constructor(address _lightClient, bytes32 _chainId, uint64 _latestHeight, bytes32 _applicationId, address _token)` +#### `constructor(address _lightClient, bytes32 _chainId, bytes32 _applicationId, address _token)` -Binds to a specific `LightClient`, chain, initial block height, Linera application ID, and ERC-20 token contract. Only messages targeting this `applicationId` are processed; all others are silently skipped. +Binds to a specific `LightClient`, chain, Linera application ID, and ERC-20 token contract. Only messages targeting this `applicationId` are processed; all others are silently skipped. #### `_onBlock(BridgeTypes.Block)` @@ -140,7 +140,7 @@ let calldata: Vec = call.abi_encode(); // Available call types: // light_client: addCommitteeCall, verifyBlockCall, currentEpochCall -// microchain: addBlockCall, nextExpectedHeightCall, lightClientCall, chainIdCall +// microchain: addBlockCall, lightClientCall, chainIdCall // Solidity sources (for compilation or deployment tooling): // BRIDGE_TYPES_SOURCE, WRAPPED_FUNGIBLE_TYPES_SOURCE, FUNGIBLE_BRIDGE_SOURCE diff --git a/linera-bridge/src/evm/microchain.rs b/linera-bridge/src/evm/microchain.rs index a00bd1f7857c..ccade5f40a45 100644 --- a/linera-bridge/src/evm/microchain.rs +++ b/linera-bridge/src/evm/microchain.rs @@ -9,8 +9,6 @@ pub const SOURCE: &str = include_str!("../solidity/Microchain.sol"); sol! { function addBlock(bytes calldata data) external; - function nextExpectedHeight() external view returns (uint64); - function lightClient() external view returns (address); function chainId() external view returns (bytes32); @@ -28,7 +26,7 @@ mod tests { primitives::Address, }; - use super::{addBlockCall, chainIdCall, lightClientCall, nextExpectedHeightCall}; + use super::{addBlockCall, chainIdCall, lightClientCall}; use crate::test_helpers::*; sol! { @@ -67,11 +65,6 @@ mod tests { microchain.add_block(BlockHeight(1)); assert_eq!(microchain.query_block_count(), 1, "block count should be 1"); - assert_eq!( - microchain.query_next_expected_height(), - 2, - "next expected height should be 2" - ); } #[test] @@ -102,13 +95,18 @@ mod tests { } #[test] - fn test_microchain_rejects_non_sequential_height() { + fn test_microchain_accepts_non_sequential_height() { let mut t = TestMicrochain::new(); - assert!( - t.try_add_block(t.chain_id, BlockHeight(5)).is_err(), - "should reject non-sequential block height" - ); + // Blocks can be submitted at any height, not just sequentially. + t.add_block(BlockHeight(5)); + assert_eq!(t.query_block_count(), 1, "block count should be 1"); + + t.add_block(BlockHeight(2)); + assert_eq!(t.query_block_count(), 2, "block count should be 2"); + + t.add_block(BlockHeight(100)); + assert_eq!(t.query_block_count(), 3, "block count should be 3"); } /// Common test state for Microchain tests. @@ -131,7 +129,7 @@ mod tests { let chain_id = CryptoHash::new(&TestString::new("test_chain")); let light_client = deploy_light_client(&mut db, deployer, &[addr], &[1], test_admin_chain_id(), 0); - let contract = deploy_microchain(&mut db, deployer, light_client, chain_id, 1); + let contract = deploy_microchain(&mut db, deployer, light_client, chain_id); Self { db, @@ -189,15 +187,5 @@ mod tests { ); count } - - fn query_next_expected_height(&mut self) -> u64 { - let (height, _, _) = call_contract( - &mut self.db, - self.deployer, - self.contract, - nextExpectedHeightCall {}, - ); - height - } } } diff --git a/linera-bridge/src/fungible_bridge.rs b/linera-bridge/src/fungible_bridge.rs index 88c6a9ce40d9..1a231b81e985 100644 --- a/linera-bridge/src/fungible_bridge.rs +++ b/linera-bridge/src/fungible_bridge.rs @@ -96,7 +96,7 @@ mod tests { let chain_id = CryptoHash::new(&TestString::new("test_chain")); let app_id = CryptoHash::new(&TestString::new("fungible_app")); let bridge = - deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, 1, app_id, token); + deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, app_id, token); // Fund the bridge with the full token supply call_contract( @@ -132,7 +132,7 @@ mod tests { self.submit_block(vec![txn]) } - /// Submits a block with the given transactions at the next sequential height. + /// Submits a block with the given transactions. fn submit_block( &mut self, transactions: Vec, @@ -311,7 +311,7 @@ mod tests { let chain_id = CryptoHash::new(&TestString::new("test_chain")); let app_id = CryptoHash::new(&TestString::new("fungible_app")); let bridge = - deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, 1, app_id, token); + deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, app_id, token); // Give depositor tokens (instead of funding the bridge) call_contract( diff --git a/linera-bridge/src/gas.rs b/linera-bridge/src/gas.rs index ffa183a1ce8b..99017a54f5fb 100644 --- a/linera-bridge/src/gas.rs +++ b/linera-bridge/src/gas.rs @@ -67,7 +67,7 @@ mod tests { let mut db = CacheDB::default(); let light_client = deploy_light_client(&mut db, deployer, &[addr], &[1], test_admin_chain_id(), 0); - let microchain = deploy_microchain(&mut db, deployer, light_client, chain_id, 1); + let microchain = deploy_microchain(&mut db, deployer, light_client, chain_id); let cert = create_signed_certificate_for_chain(&secret, &public, chain_id, BlockHeight(1)); let bcs_bytes = bcs::to_bytes(&cert).expect("BCS serialization failed"); diff --git a/linera-bridge/src/lib.rs b/linera-bridge/src/lib.rs index ee06ca06e690..c17d7d369d1b 100644 --- a/linera-bridge/src/lib.rs +++ b/linera-bridge/src/lib.rs @@ -14,6 +14,10 @@ pub mod proof; #[cfg(feature = "offchain")] pub mod evm; +/// Bridge monitoring: tracks in-flight EVM↔Linera bridging requests. +#[cfg(feature = "relay")] +pub mod monitor; + /// Relay server: HTTP proof endpoint + Linera chain inbox processing + EVM block forwarding. #[cfg(feature = "relay")] pub mod relay; diff --git a/linera-bridge/src/main.rs b/linera-bridge/src/main.rs index fd0cb4f6f50b..dc181fa0ab8a 100644 --- a/linera-bridge/src/main.rs +++ b/linera-bridge/src/main.rs @@ -18,7 +18,7 @@ enum Cli { GenerateDepositProof(GenerateDepositProofOptions), /// Run the relay server (proof generation + chain inbox processing + EVM forwarding) #[cfg(feature = "relay")] - Serve(ServeOptions), + Serve(Box), } #[derive(clap::Args, Debug, Clone)] @@ -54,26 +54,41 @@ struct ServeOptions { #[arg(long)] rpc_url: String, - /// URL of the Linera faucet + /// URL of the Linera faucet (required when wallet doesn't exist or chain ID not provided) #[arg(long)] - faucet_url: String, + faucet_url: Option, - /// Address of the FungibleBridge contract on EVM. - /// If omitted, reads from --bridge-address-file (polls until available). + /// Path to the wallet state file. + #[arg(long = "wallet", env = "LINERA_WALLET")] + wallet: Option, + + /// Path to the keystore file. + #[arg(long = "keystore", env = "LINERA_KEYSTORE")] + keystore: Option, + + /// Storage configuration for blockchain history (e.g. rocksdb:/path/to/db). + #[arg(long = "storage", env = "LINERA_STORAGE")] + storage: Option, + + /// Linera bridge chain ID. If omitted, claims a new chain from faucet. #[arg(long)] - bridge_address: Option, + linera_bridge_chain_id: Option, - /// File to read bridge address from (used when bridge is deployed after relay starts) - #[arg(long, default_value = "/shared/bridge-address")] - bridge_address_file: String, + /// Owner to use for the bridge chain. Required when --linera-bridge-chain-id is provided. + #[arg(long, requires = "linera_bridge_chain_id")] + linera_bridge_chain_owner: Option, - /// File to read the evm-bridge ApplicationId from (written by setup script) - #[arg(long, default_value = "/shared/bridge-app-id")] - bridge_app_id_file: String, + /// Address of the FungibleBridge contract on EVM. + #[arg(long)] + evm_bridge_address: String, - /// File to read the wrapped-fungible ApplicationId from (written by setup script) - #[arg(long, default_value = "/shared/wrapped-app-id")] - fungible_app_id_file: String, + /// evm-bridge Linera ApplicationId (hex). + #[arg(long)] + linera_bridge_address: String, + + /// wrapped-fungible Linera ApplicationId (hex). + #[arg(long)] + linera_fungible_address: String, /// EVM private key for signing addBlock transactions #[arg(long)] @@ -82,6 +97,43 @@ struct ServeOptions { /// Port to listen on for HTTP requests #[arg(long, default_value = "3001")] port: u16, + + /// The maximal number of entries in the blob cache. + #[arg(long, default_value = "1000")] + blob_cache_size: usize, + + /// The maximal number of entries in the confirmed block cache. + #[arg(long, default_value = "1000")] + confirmed_block_cache_size: usize, + + /// The maximal number of entries in the lite certificate cache. + #[arg(long, default_value = "1000")] + lite_certificate_cache_size: usize, + + /// The maximal number of entries in the raw certificate cache. + #[arg(long, default_value = "1000")] + certificate_raw_cache_size: usize, + + /// The maximal number of entries in the event cache. + #[arg(long, default_value = "1000")] + event_cache_size: usize, + + /// Interval between monitor scan loops, in seconds. + #[arg(long, default_value = "30")] + monitor_scan_interval: u64, + + /// EVM block number to start scanning from for deposit monitoring. + #[arg(long, default_value = "0")] + monitor_start_block: u64, + + /// Maximum number of retry attempts for pending deposits and burns. + #[arg(long, default_value = "10")] + max_retries: u32, + + /// Path to the SQLite database for persistent request storage. + /// Defaults to `bridge_relay.sqlite3` next to the RocksDB storage directory. + #[arg(long)] + sqlite_path: Option, } fn main() -> Result<()> { @@ -100,16 +152,31 @@ fn main() -> Result<()> { #[cfg(feature = "relay")] impl ServeOptions { async fn run(&self) -> Result<()> { - linera_bridge::relay::run( + Box::pin(linera_bridge::relay::run( &self.rpc_url, - &self.faucet_url, - self.bridge_address.as_deref(), - &self.bridge_address_file, - &self.bridge_app_id_file, - &self.fungible_app_id_file, + self.faucet_url.as_deref(), + self.wallet.as_deref(), + self.keystore.as_deref(), + self.storage.as_deref(), + self.linera_bridge_chain_id, + self.linera_bridge_chain_owner, + &self.evm_bridge_address, + &self.linera_bridge_address, + &self.linera_fungible_address, &self.evm_private_key, self.port, - ) + linera_storage::StorageCacheSizes { + blob_cache_size: self.blob_cache_size, + confirmed_block_cache_size: self.confirmed_block_cache_size, + lite_certificate_cache_size: self.lite_certificate_cache_size, + certificate_raw_cache_size: self.certificate_raw_cache_size, + event_cache_size: self.event_cache_size, + }, + self.monitor_scan_interval, + self.monitor_start_block, + self.max_retries, + self.sqlite_path.as_deref(), + )) .await } } @@ -133,7 +200,7 @@ impl GenerateDepositProofOptions { "block_header_rlp": alloy_primitives::hex::encode_prefixed(&proof.block_header_rlp), "receipt_rlp": alloy_primitives::hex::encode_prefixed(&proof.receipt_rlp), "proof_nodes": proof.proof_nodes.iter() - .map(|n| alloy_primitives::hex::encode_prefixed(n)) + .map(alloy_primitives::hex::encode_prefixed) .collect::>(), "tx_index": proof.tx_index, "log_indices": proof.log_indices, diff --git a/linera-bridge/src/monitor/db.rs b/linera-bridge/src/monitor/db.rs new file mode 100644 index 000000000000..698c1a383a16 --- /dev/null +++ b/linera-bridge/src/monitor/db.rs @@ -0,0 +1,436 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! SQLite persistent storage for bridge relayer deposit/burn requests. +//! +//! This is a write-through layer alongside the in-memory `MonitorState`. +//! It persists request metadata and raw operation bytes so they can be +//! queried and replayed without the relayer running. + +use std::path::Path; + +use anyhow::Result; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, + SqlitePool, +}; + +use super::{PendingBurn, PendingDeposit}; +use crate::proof::DepositKey; + +/// Persistent SQLite store for bridging requests. +pub struct BridgeDb { + pool: SqlitePool, +} + +impl BridgeDb { + /// Opens (or creates) the SQLite database at `path` and runs migrations. + pub async fn open(path: &Path) -> Result { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + let db = Self { pool }; + db.create_tables().await?; + Ok(db) + } + + /// Opens an in-memory database for testing. + #[cfg(test)] + pub async fn open_in_memory() -> Result { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await?; + let db = Self { pool }; + db.create_tables().await?; + Ok(db) + } + + async fn create_tables(&self) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS deposits ( + source_chain_id INTEGER NOT NULL, + block_hash BLOB NOT NULL, + tx_index INTEGER NOT NULL, + log_index INTEGER NOT NULL, + tx_hash BLOB NOT NULL, + depositor BLOB NOT NULL, + amount TEXT NOT NULL, + nonce TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + raw_operation BLOB, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (source_chain_id, block_hash, tx_index, log_index) + )", + ) + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_deposits_status ON deposits(status)") + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_deposits_depositor ON deposits(depositor)") + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS burns ( + linera_height INTEGER NOT NULL, + burn_index INTEGER NOT NULL, + evm_recipient TEXT NOT NULL, + amount TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + raw_cert BLOB, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (linera_height, burn_index) + )", + ) + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_burns_status ON burns(status)") + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_burns_evm_recipient ON burns(evm_recipient)") + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Inserts a new deposit. Ignores duplicates (idempotent). + pub async fn insert_deposit(&self, deposit: &PendingDeposit) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO deposits + (source_chain_id, block_hash, tx_index, log_index, tx_hash, depositor, amount, nonce) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(deposit.key.source_chain_id as i64) + .bind(deposit.key.block_hash.as_slice()) + .bind(deposit.key.tx_index as i64) + .bind(deposit.key.log_index as i64) + .bind(deposit.tx_hash.as_slice()) + .bind(deposit.depositor.as_slice()) + .bind(deposit.amount.to_string()) + .bind(deposit.nonce.to_string()) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Updates a deposit's status and timestamp. + pub async fn update_deposit_status(&self, key: &DepositKey, status: &str) -> Result<()> { + sqlx::query( + "UPDATE deposits SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE source_chain_id = ? AND block_hash = ? AND tx_index = ? AND log_index = ?", + ) + .bind(status) + .bind(key.source_chain_id as i64) + .bind(key.block_hash.as_slice()) + .bind(key.tx_index as i64) + .bind(key.log_index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Stores raw BCS-serialized operation bytes for a deposit. + pub async fn store_deposit_raw(&self, key: &DepositKey, raw: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE deposits SET raw_operation = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE source_chain_id = ? AND block_hash = ? AND tx_index = ? AND log_index = ?", + ) + .bind(raw) + .bind(key.source_chain_id as i64) + .bind(key.block_hash.as_slice()) + .bind(key.tx_index as i64) + .bind(key.log_index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Inserts a new burn. Ignores duplicates (idempotent). + pub async fn insert_burn(&self, burn: &PendingBurn) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO burns (linera_height, burn_index, evm_recipient, amount) + VALUES (?, ?, ?, ?)", + ) + .bind(burn.linera_height as i64) + .bind(burn.burn_index as i64) + .bind(&burn.evm_recipient) + .bind(&burn.amount) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Updates a burn's status and timestamp. + pub async fn update_burn_status(&self, height: u64, index: usize, status: &str) -> Result<()> { + sqlx::query( + "UPDATE burns SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE linera_height = ? AND burn_index = ?", + ) + .bind(status) + .bind(height as i64) + .bind(index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Stores raw BCS-serialized certificate bytes for a burn. + pub async fn store_burn_raw(&self, height: u64, index: usize, raw: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE burns SET raw_cert = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE linera_height = ? AND burn_index = ?", + ) + .bind(raw) + .bind(height as i64) + .bind(index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU32, Ordering}; + + use alloy::primitives::{Address, B256, U256}; + use test_case::test_case; + + use super::*; + + static FILE_COUNTER: AtomicU32 = AtomicU32::new(0); + + async fn open_db(use_file: bool) -> BridgeDb { + if use_file { + let n = FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::path::PathBuf::from(format!("/tmp/bridge_db_test_{n}.sqlite3")); + let _ = std::fs::remove_file(&path); + BridgeDb::open(&path).await.unwrap() + } else { + BridgeDb::open_in_memory().await.unwrap() + } + } + + fn test_deposit_key() -> DepositKey { + DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 0, + } + } + + fn test_deposit() -> PendingDeposit { + PendingDeposit { + key: test_deposit_key(), + tx_hash: B256::from([0xBB; 32]), + depositor: Address::from([0xCC; 20]), + amount: U256::from(1_000_000u64), + nonce: U256::from(42u64), + } + } + + fn test_burn() -> PendingBurn { + PendingBurn { + linera_height: 100, + burn_index: 0, + evm_recipient: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(), + amount: "500000".to_string(), + } + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_and_query_deposit(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + + let row: (String, Vec, String) = sqlx::query_as( + "SELECT amount, depositor, status FROM deposits WHERE source_chain_id = 8453", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(row.0, "1000000"); + assert_eq!(row.1, Address::from([0xCC; 20]).as_slice()); + assert_eq!(row.2, "pending"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_complete_deposit(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + db.update_deposit_status(&test_deposit_key(), "completed") + .await + .unwrap(); + + let (status,): (String,) = + sqlx::query_as("SELECT status FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "completed"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_fail_deposit(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + db.update_deposit_status(&test_deposit_key(), "failed") + .await + .unwrap(); + + let (status,): (String,) = + sqlx::query_as("SELECT status FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "failed"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_deposit_idempotent(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + db.insert_deposit(&test_deposit()).await.unwrap(); + + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM deposits") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_store_and_retrieve_deposit_raw(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + + let raw_bytes = vec![1, 2, 3, 4, 5]; + db.store_deposit_raw(&test_deposit_key(), &raw_bytes) + .await + .unwrap(); + + let (raw,): (Vec,) = + sqlx::query_as("SELECT raw_operation FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(raw, raw_bytes); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_and_query_burn(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + + let row: (String, String, String) = sqlx::query_as( + "SELECT evm_recipient, amount, status FROM burns WHERE linera_height = 100", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(row.0, "0xabcdef1234567890abcdef1234567890abcdef12"); + assert_eq!(row.1, "500000"); + assert_eq!(row.2, "pending"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_complete_burn(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + db.update_burn_status(100, 0, "completed").await.unwrap(); + + let (status,): (String,) = + sqlx::query_as("SELECT status FROM burns WHERE linera_height = 100") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "completed"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_burn_idempotent(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + db.insert_burn(&test_burn()).await.unwrap(); + + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM burns") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_store_and_retrieve_burn_raw(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + + let cert_bytes = vec![10, 20, 30, 40, 50]; + db.store_burn_raw(100, 0, &cert_bytes).await.unwrap(); + + let (raw,): (Vec,) = + sqlx::query_as("SELECT raw_cert FROM burns WHERE linera_height = 100") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(raw, cert_bytes); + } + + #[tokio::test] + async fn test_file_persistence_survives_reopen() { + let path = std::path::PathBuf::from("/tmp/bridge_db_test_reopen.sqlite3"); + let _ = std::fs::remove_file(&path); + + { + let db = BridgeDb::open(&path).await.unwrap(); + db.insert_deposit(&test_deposit()).await.unwrap(); + db.update_deposit_status(&test_deposit_key(), "completed") + .await + .unwrap(); + } + + { + let db = BridgeDb::open(&path).await.unwrap(); + let (status,): (String,) = + sqlx::query_as("SELECT status FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "completed"); + } + + let _ = std::fs::remove_file(&path); + } +} diff --git a/linera-bridge/src/monitor/evm.rs b/linera-bridge/src/monitor/evm.rs new file mode 100644 index 000000000000..4c04e0fc63c0 --- /dev/null +++ b/linera-bridge/src/monitor/evm.rs @@ -0,0 +1,223 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! EVM-side monitoring: scans for `DepositInitiated` events, checks Linera for +//! completion, and retries pending deposits. + +use std::{sync::Arc, time::Duration}; + +use alloy::providers::Provider; +use tokio::sync::RwLock; + +use super::{MonitorState, PendingDeposit}; +use crate::{ + proof::{parse_deposit_event, DepositKey, ReceiptLog}, + relay::{evm::EvmClient, linera::LineraClient}, +}; + +/// Background task that polls EVM for `DepositInitiated` events and checks +/// Linera for completion. +pub async fn evm_scan_loop( + monitor: Arc>, + evm_client: Arc>, + linera_client: Arc>, + pending_deposit_tx: tokio::sync::mpsc::Sender, + scan_interval: Duration, + max_retries: u32, +) { + loop { + let (scan_result, completion_result) = tokio::join!( + evm_scan_iteration(&monitor, &evm_client, &pending_deposit_tx), + check_deposit_completion(&monitor, &linera_client), + ); + + if let Err(error) = scan_result { + tracing::warn!(error = ?error, "EVM scan iteration failed"); + } + if let Err(error) = completion_result { + tracing::warn!(error = ?error, "Deposit completion check failed"); + } + + // Re-enqueue deposits that are eligible for retry. + { + let state = monitor.read().await; + for d in state.deposits_ready_for_retry(max_retries) { + let _ = pending_deposit_tx.try_send(PendingDeposit { + key: d.value.key.clone(), + tx_hash: d.value.tx_hash, + depositor: d.value.depositor, + amount: d.value.amount, + nonce: d.value.nonce, + }); + } + } + + let summary = monitor.read().await.status_summary(); + tracing::trace!( + pending = summary.deposits_pending, + completed = summary.deposits_completed, + last_block = summary.last_scanned_evm_block, + "EVM deposit scan complete" + ); + + tokio::time::sleep(scan_interval).await; + } +} + +/// Receives pending deposits from the scanner and retries them. +pub(crate) async fn retry_pending_deposits( + monitor: &RwLock, + linera_client: &LineraClient, + proof_client: &crate::proof::gen::HttpDepositProofClient, + mut pending_rx: tokio::sync::mpsc::Receiver, +) -> anyhow::Result<()> { + use crate::proof::gen::DepositProofClient as _; + + while let Some(pending) = pending_rx.recv().await { + let tx_hash = pending.tx_hash; + { + let mut state = monitor.write().await; + if let Some(d) = state.deposits.get_mut(&pending.key) { + if d.forwarded { + tracing::trace!(%tx_hash, "Deposit already completed, skipping"); + continue; + } + d.last_retry_at = Some(std::time::Instant::now()); + } else { + state.track_deposit(pending.clone()).await; + } + } + + tracing::info!(%tx_hash, "Processing pending deposit..."); + match proof_client.generate_deposit_proof(tx_hash).await { + Ok(proof) => { + // Persist raw BCS operation bytes so deposits can be replayed without the relayer. + if let Some(db) = monitor.read().await.db() { + for &log_index in &proof.log_indices { + let op = crate::relay::evm::BridgeOperation::ProcessDeposit { + block_header_rlp: proof.block_header_rlp.clone(), + receipt_rlp: proof.receipt_rlp.clone(), + proof_nodes: proof.proof_nodes.clone(), + tx_index: proof.tx_index, + log_index, + }; + if let Ok(raw) = bcs::to_bytes(&op) { + if let Err(e) = db.store_deposit_raw(&pending.key, &raw).await { + tracing::warn!(%tx_hash, "Failed to store deposit raw bytes: {e:#}"); + } + } + } + } + + match linera_client.process_deposit(proof).await { + Ok(()) => { + tracing::info!(%tx_hash, "Deposit processed successfully"); + } + Err(e) => { + tracing::warn!(%tx_hash, "Deposit processing failed: {e}"); + } + } + } + Err(e) => { + tracing::warn!(%tx_hash, "Proof generation failed: {e:#}"); + } + } + + monitor.write().await.mark_deposit_retried(&pending.key); + } + + anyhow::bail!("Pending deposit channel closed"); +} + +async fn evm_scan_iteration( + monitor: &RwLock, + evm_client: &EvmClient, + pending_tx: &tokio::sync::mpsc::Sender, +) -> anyhow::Result<()> { + let last_block = monitor.read().await.last_scanned_evm_block; + + let current_block = evm_client.get_block_number().await?; + if current_block <= last_block { + return Ok(()); + } + + let logs = evm_client + .get_deposit_logs(last_block + 1, current_block) + .await?; + let bridge_addr = evm_client.bridge_addr(); + + for log in &logs { + let block_hash = match log.block_hash { + Some(h) => h, + None => continue, + }; + let tx_hash = match log.transaction_hash { + Some(h) => h, + None => continue, + }; + let tx_index = match log.transaction_index { + Some(i) => i, + None => continue, + }; + let log_index = match log.log_index { + Some(i) => i, + None => continue, + }; + + let receipt_log = ReceiptLog { + address: log.address(), + topics: log.data().topics().to_vec(), + data: log.data().data.to_vec(), + }; + let deposit = match parse_deposit_event(&receipt_log, bridge_addr) { + Ok(d) => d, + Err(e) => { + tracing::warn!(%tx_hash, "Failed to parse DepositInitiated log: {e:#}"); + continue; + } + }; + + let key = DepositKey { + source_chain_id: deposit.source_chain_id.to::(), + block_hash: block_hash.0, + tx_index, + log_index, + }; + + let _ = pending_tx.try_send(PendingDeposit { + key, + tx_hash, + depositor: deposit.depositor, + amount: deposit.amount, + nonce: deposit.nonce, + }); + } + + let mut state = monitor.write().await; + state.last_scanned_evm_block = current_block; + crate::relay::metrics::set_last_scanned_evm_block(current_block); + + Ok(()) +} + +async fn check_deposit_completion( + monitor: &RwLock, + linera_client: &LineraClient, +) -> anyhow::Result<()> { + let pending: Vec = { + let state = monitor.read().await; + state + .pending_deposits() + .into_iter() + .map(|d| d.value.key.clone()) + .collect() + }; + + for key in pending { + if linera_client.query_deposit_processed(&key).await? { + monitor.write().await.complete_deposit(&key).await; + } + } + + Ok(()) +} diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs new file mode 100644 index 000000000000..da48c4b729bd --- /dev/null +++ b/linera-bridge/src/monitor/linera.rs @@ -0,0 +1,266 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Linera-side monitoring: scans for Credit-to-Address20 messages (burns), +//! checks EVM for completion via ERC-20 Transfer events, and retries +//! unforwarded burns. + +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use alloy::{primitives::Address, providers::Provider}; +use linera_base::{data_types::Amount, identifiers::AccountOwner}; +use tokio::sync::RwLock; + +use super::{MonitorState, PendingBurn}; +use crate::relay::{ + evm::EvmClient, + linera::{find_address20_credits, LineraClient}, +}; + +/// Background task that scans Linera block history for Credit messages +/// to Address20 owners and checks EVM for completion. +pub async fn linera_scan_loop( + monitor: Arc>, + evm_client: Arc>, + linera_client: Arc>, + pending_burn_tx: tokio::sync::mpsc::Sender, + scan_interval: Duration, + max_retries: u32, +) { + loop { + let (scan_result, completion_result) = tokio::join!( + linera_scan_iteration(&monitor, &linera_client, &pending_burn_tx), + check_burn_completion(&monitor, &evm_client), + ); + + if let Err(error) = scan_result { + tracing::warn!(?error, "Linera scan iteration failed"); + } + if let Err(error) = completion_result { + tracing::warn!(?error, "Burn completion check failed"); + } + + // Re-enqueue burns that are eligible for retry. + { + let state = monitor.read().await; + for b in state.burns_ready_for_retry(max_retries) { + let _ = pending_burn_tx.try_send(PendingBurn { + linera_height: b.value.linera_height, + burn_index: b.value.burn_index, + evm_recipient: b.value.evm_recipient.clone(), + amount: b.value.amount.clone(), + }); + } + } + + let summary = monitor.read().await.status_summary(); + tracing::trace!( + pending = summary.burns_pending, + completed = summary.burns_forwarded, + last_height = summary.last_scanned_linera_height, + "Linera burn scan complete" + ); + + tokio::time::sleep(scan_interval).await; + } +} + +/// Receives pending burns, submits Burn via LineraClient, forwards cert via EvmClient. +pub(crate) async fn retry_pending_burns( + monitor: &RwLock, + evm_client: &EvmClient, + linera_client: &LineraClient, + mut pending_rx: tokio::sync::mpsc::Receiver, +) -> anyhow::Result<()> { + while let Some(pending) = pending_rx.recv().await { + let credit_height = pending.linera_height; + let burn_index = pending.burn_index; + let owner: AccountOwner = match pending.evm_recipient.parse() { + Ok(o) => o, + Err(_) => { + tracing::warn!( + evm_recipient = %pending.evm_recipient, + "Invalid recipient, skipping burn" + ); + continue; + } + }; + let amount: Amount = match pending.amount.parse() { + Ok(a) => a, + Err(_) => { + tracing::warn!(amount = %pending.amount, "Invalid amount, skipping burn"); + continue; + } + }; + { + let mut state = monitor.write().await; + if let Some(b) = state.burns.get_mut(&(credit_height, burn_index)) { + if b.forwarded { + tracing::trace!( + credit_height, + burn_index, + "Burn already completed, skipping" + ); + continue; + } + b.last_retry_at = Some(Instant::now()); + } else { + state.track_burn(pending).await; + } + } + + tracing::info!(credit_height, burn_index, %owner, %amount, "Processing burn..."); + + // Step 1: Submit Burn on Linera via the main loop channel. + let cert = match linera_client.burn(owner, amount).await { + Ok(cert) => cert, + Err(e) => { + tracing::warn!(credit_height, burn_index, "Burn submission failed: {e:#}"); + monitor + .write() + .await + .mark_burn_retried(credit_height, burn_index); + continue; + } + }; + + // Persist raw BCS cert bytes so burns can be replayed without the relayer. + let cert_bytes = + bcs::to_bytes(&cert).expect("failed to BCS-serialize ConfirmedBlockCertificate"); + if let Some(db) = monitor.read().await.db() { + if let Err(e) = db + .store_burn_raw(credit_height, burn_index, &cert_bytes) + .await + { + tracing::warn!( + credit_height, + burn_index, + "Failed to store burn raw bytes: {e:#}" + ); + } + } + + // Step 2: Forward cert to EVM directly (no chain conflict). + let completed = match evm_client.forward_cert(&cert).await { + Ok(()) => { + tracing::info!(credit_height, burn_index, "Burn forwarded to EVM"); + true + } + Err(e) => { + let msg = format!("{e:#}"); + if msg.contains("already verified") { + tracing::trace!(credit_height, burn_index, "Block already verified on EVM"); + true + } else { + tracing::warn!(credit_height, burn_index, "EVM forwarding failed: {e:#}"); + monitor + .write() + .await + .mark_burn_retried(credit_height, burn_index); + false + } + } + }; + + if completed { + monitor + .write() + .await + .complete_burn(credit_height, burn_index) + .await; + } + } + + anyhow::bail!("Pending burn channel closed"); +} + +async fn linera_scan_iteration( + monitor: &RwLock, + linera_client: &LineraClient, + pending_burn_tx: &tokio::sync::mpsc::Sender, +) -> anyhow::Result<()> { + let last_height = monitor.read().await.last_scanned_linera_height; + + linera_client.sync().await?; + let info = linera_client.chain_info().await?; + let current_height = info.next_block_height.0; + if current_height == 0 || current_height <= last_height { + return Ok(()); + } + + let fungible_app_id = linera_client.fungible_app_id(); + + let mut blocks = Vec::new(); + let mut hash = info.block_hash; + while let Some(h) = hash { + let block = linera_client.read_confirmed_block(h).await?; + let height = block.block().header.height.0; + if height < last_height { + break; + } + hash = block.block().header.previous_block_hash; + blocks.push(block); + } + blocks.reverse(); + + let mut new_burns = Vec::new(); + for block in &blocks { + let height = block.block().header.height.0; + let credits = find_address20_credits(&block.block().body.transactions, fungible_app_id); + for (burn_index, (owner, amount)) in credits.into_iter().enumerate() { + new_burns.push((height, burn_index, format!("{owner}"), amount.to_string())); + } + } + + for (height, burn_index, recipient, amount) in &new_burns { + tracing::info!(height, burn_index, recipient, amount, "Discovered burn"); + let _ = pending_burn_tx.try_send(PendingBurn { + linera_height: *height, + burn_index: *burn_index, + evm_recipient: recipient.clone(), + amount: amount.clone(), + }); + } + + let mut state = monitor.write().await; + state.last_scanned_linera_height = current_height; + crate::relay::metrics::set_last_scanned_linera_height(current_height); + Ok(()) +} + +async fn check_burn_completion( + monitor: &RwLock, + evm_client: &EvmClient, +) -> anyhow::Result<()> { + let pending: Vec<(u64, usize, Address)> = { + let state = monitor.read().await; + state + .pending_burns() + .into_iter() + .filter_map(|b| { + let addr: Address = b.value.evm_recipient.parse().ok()?; + Some((b.value.linera_height, b.value.burn_index, addr)) + }) + .collect() + }; + + if pending.is_empty() { + return Ok(()); + } + + for (height, burn_index, recipient) in pending { + let logs = evm_client.get_transfer_logs(recipient).await?; + if !logs.is_empty() { + monitor + .write() + .await + .complete_burn(height, burn_index) + .await; + } + } + + Ok(()) +} diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs new file mode 100644 index 000000000000..8bb248ac4b0b --- /dev/null +++ b/linera-bridge/src/monitor/mod.rs @@ -0,0 +1,522 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Bridge monitoring: tracks in-flight EVM↔Linera bridging requests. +//! +//! Two background scan loops actively poll both chains: +//! - **EVM scan** ([`evm`]): queries `DepositInitiated` events, checks Linera for completion. +//! - **Linera scan** ([`linera`]): walks block history for Credit-to-Address20 messages, +//! checks EVM for completion via ERC-20 `Transfer` events. + +pub mod db; +pub mod evm; +pub mod linera; + +use std::{ + collections::{hash_map::Entry, HashMap}, + sync::Arc, + time::{Duration, Instant}, +}; + +use alloy::primitives::{Address, B256, U256}; +use linera_base::identifiers::ApplicationId; +use linera_execution::{Query, QueryResponse}; +use tokio::sync::RwLock; + +use crate::proof::DepositKey; + +/// Queries the evm-bridge app to check whether a deposit has been processed on Linera. +pub async fn query_deposit_processed( + chain_client: &linera_core::client::ChainClient, + bridge_app_id: ApplicationId, + deposit_key: &DepositKey, +) -> anyhow::Result { + let hash_hex = format!("0x{}", hex::encode(deposit_key.hash())); + let gql = format!(r#"{{ isDepositProcessed(hash: "{hash_hex}") }}"#); + let query = Query::user_without_abi(bridge_app_id, &GqlRequest { query: gql })?; + let (outcome, _) = chain_client.query_application(query, None).await?; + let response_bytes = match outcome.response { + QueryResponse::User(bytes) => bytes, + other => anyhow::bail!("unexpected query response: {other:?}"), + }; + let response: serde_json::Value = serde_json::from_slice(&response_bytes)?; + Ok(response["data"]["isDepositProcessed"].as_bool() == Some(true)) +} + +/// A pending deposit detected by the EVM scanner, sent to the retry loop. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingDeposit { + pub key: DepositKey, + pub tx_hash: B256, + pub depositor: Address, + pub amount: U256, + pub nonce: U256, +} + +/// A pending burn detected by the Linera scanner, sent to the retry loop. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingBurn { + pub linera_height: u64, + pub burn_index: usize, + pub evm_recipient: String, + pub amount: String, +} + +/// Wraps a pending bridging request with tracking metadata. +#[derive(Debug, Clone, serde::Serialize)] +pub struct Tracked { + #[serde(flatten)] + pub value: T, + pub forwarded: bool, + pub failed: bool, + #[serde(skip)] + pub retry_count: u32, + #[serde(skip)] + pub last_retry_at: Option, +} + +impl Tracked { + fn new(value: T) -> Self { + Self { + value, + forwarded: false, + failed: false, + retry_count: 0, + last_retry_at: None, + } + } +} + +pub type TrackedDeposit = Tracked; +pub type TrackedBurn = Tracked; + +/// In-memory monitoring state shared across scan loops and HTTP handlers. +pub struct MonitorState { + pub(crate) deposits: HashMap, + pub(crate) burns: HashMap<(u64, usize), TrackedBurn>, + pub(crate) last_scanned_evm_block: u64, + pub(crate) last_scanned_linera_height: u64, + db: Option, +} + +impl MonitorState { + pub fn new(start_evm_block: u64) -> Self { + Self { + deposits: HashMap::new(), + burns: HashMap::new(), + last_scanned_evm_block: start_evm_block, + last_scanned_linera_height: 0, + db: None, + } + } + + /// Sets the persistent SQLite database for write-through storage. + pub fn set_db(&mut self, db: db::BridgeDb) { + self.db = Some(db); + } + + /// Returns a reference to the database, if configured. + pub fn db(&self) -> Option<&db::BridgeDb> { + self.db.as_ref() + } + + /// Tracks a deposit. Returns `true` if this is a newly discovered deposit. + /// Uses Entry API instead of insert() to avoid overwriting existing entries + /// that may have accumulated retry state. + pub async fn track_deposit(&mut self, pending: PendingDeposit) -> bool { + match self.deposits.entry(pending.key.clone()) { + Entry::Occupied(_) => false, + Entry::Vacant(e) => { + if let Some(db) = &self.db { + if let Err(e) = db.insert_deposit(&pending).await { + tracing::warn!("Failed to persist deposit to SQLite: {e:#}"); + } + } + e.insert(Tracked::new(pending)); + crate::relay::metrics::deposit_detected(); + true + } + } + } + + pub async fn complete_deposit(&mut self, key: &DepositKey) { + if let Some(d) = self.deposits.get_mut(key) { + d.forwarded = true; + crate::relay::metrics::deposit_completed(); + if let Some(db) = &self.db { + if let Err(e) = db.update_deposit_status(key, "completed").await { + tracing::warn!(?key, "Failed to update deposit status in SQLite: {e:#}"); + } + } + } else { + tracing::warn!(deposit_id = ?key, "Attempted to complete unknown deposit"); + } + } + + /// Tracks a burn. Returns `true` if this is a newly discovered burn. + /// Uses Entry API instead of insert() to avoid overwriting existing entries + /// that may have accumulated retry state. + pub async fn track_burn(&mut self, pending: PendingBurn) -> bool { + let key = (pending.linera_height, pending.burn_index); + match self.burns.entry(key) { + Entry::Occupied(_) => false, + Entry::Vacant(e) => { + if let Some(db) = &self.db { + if let Err(err) = db.insert_burn(&pending).await { + tracing::warn!("Failed to persist burn to SQLite: {err:#}"); + } + } + e.insert(Tracked::new(pending)); + crate::relay::metrics::burn_detected(); + true + } + } + } + + pub async fn complete_burn(&mut self, linera_height: u64, burn_index: usize) { + if let Some(b) = self.burns.get_mut(&(linera_height, burn_index)) { + b.forwarded = true; + crate::relay::metrics::burn_completed(); + if let Some(db) = &self.db { + if let Err(e) = db + .update_burn_status(linera_height, burn_index, "completed") + .await + { + tracing::warn!( + linera_height, + burn_index, + "Failed to update burn status in SQLite: {e:#}" + ); + } + } + } else { + tracing::warn!( + linera_height, + burn_index, + "Attempted to complete unknown burn" + ); + } + } + + pub fn all_deposits(&self) -> Vec<&TrackedDeposit> { + self.deposits.values().collect() + } + + pub fn pending_deposits(&self) -> Vec<&TrackedDeposit> { + self.deposits.values().filter(|d| !d.forwarded).collect() + } + + pub fn completed_deposits(&self) -> Vec<&TrackedDeposit> { + self.deposits.values().filter(|d| d.forwarded).collect() + } + + pub fn all_burns(&self) -> Vec<&TrackedBurn> { + self.burns.values().collect() + } + + pub fn pending_burns(&self) -> Vec<&TrackedBurn> { + self.burns.values().filter(|b| !b.forwarded).collect() + } + + pub fn completed_burns(&self) -> Vec<&TrackedBurn> { + self.burns.values().filter(|b| b.forwarded).collect() + } + + pub fn deposits_ready_for_retry(&self, max_retries: u32) -> Vec<&TrackedDeposit> { + self.deposits + .values() + .filter(|d| { + !d.forwarded + && !d.failed + && retry_eligible(d.retry_count, d.last_retry_at, max_retries) + }) + .collect() + } + + pub fn burns_ready_for_retry(&self, max_retries: u32) -> Vec<&TrackedBurn> { + self.burns + .values() + .filter(|b| { + !b.forwarded + && !b.failed + && retry_eligible(b.retry_count, b.last_retry_at, max_retries) + }) + .collect() + } + + pub fn mark_deposit_retried(&mut self, key: &DepositKey) { + if let Some(d) = self.deposits.get_mut(key) { + d.retry_count += 1; + d.last_retry_at = Some(Instant::now()); + } + } + + pub async fn mark_deposit_failed(&mut self, key: &DepositKey) { + if let Some(d) = self.deposits.get_mut(key) { + d.failed = true; + crate::relay::metrics::deposit_failed(); + if let Some(db) = &self.db { + if let Err(e) = db.update_deposit_status(key, "failed").await { + tracing::warn!(?key, "Failed to update deposit status in SQLite: {e:#}"); + } + } + } + } + + pub fn mark_burn_retried(&mut self, height: u64, burn_index: usize) { + if let Some(b) = self.burns.get_mut(&(height, burn_index)) { + b.retry_count += 1; + b.last_retry_at = Some(Instant::now()); + } + } + + pub async fn mark_burn_failed(&mut self, height: u64, burn_index: usize) { + if let Some(b) = self.burns.get_mut(&(height, burn_index)) { + b.failed = true; + crate::relay::metrics::burn_failed(); + if let Some(db) = &self.db { + if let Err(e) = db.update_burn_status(height, burn_index, "failed").await { + tracing::warn!( + height, + burn_index, + "Failed to update burn status in SQLite: {e:#}" + ); + } + } + } + } + + pub fn status_summary(&self) -> StatusSummary { + StatusSummary { + deposits_pending: self.deposits.values().filter(|d| !d.forwarded).count(), + deposits_completed: self.deposits.values().filter(|d| d.forwarded).count(), + burns_pending: self.burns.values().filter(|b| !b.forwarded).count(), + burns_forwarded: self.burns.values().filter(|b| b.forwarded).count(), + last_scanned_evm_block: self.last_scanned_evm_block, + last_scanned_linera_height: self.last_scanned_linera_height, + } + } +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct StatusSummary { + pub deposits_pending: usize, + pub deposits_completed: usize, + pub burns_pending: usize, + pub burns_forwarded: usize, + pub last_scanned_evm_block: u64, + pub last_scanned_linera_height: u64, +} + +/// Runs deposit and burn retry loops concurrently. +/// Returns if either encounters an unrecoverable error. +pub(crate) async fn retry_loop( + monitor: Arc>, + proof_client: crate::proof::gen::HttpDepositProofClient, + evm_client: Arc>, + linera_client: Arc>, + pending_deposit_rx: tokio::sync::mpsc::Receiver, + pending_burn_rx: tokio::sync::mpsc::Receiver, +) -> anyhow::Result<()> { + tokio::select! { + result = evm::retry_pending_deposits( + &monitor, &linera_client, &proof_client, pending_deposit_rx, + ) => result, + result = linera::retry_pending_burns( + &monitor, &evm_client, &linera_client, pending_burn_rx, + ) => result, + } +} + +/// GraphQL request body for application queries. +#[derive(serde::Serialize)] +struct GqlRequest { + query: String, +} + +/// Whether an item is eligible for retry based on exponential backoff. +/// Backoff schedule: 5s, 10s, 20s, 40s, 80s (capped). +fn retry_eligible(retry_count: u32, last_retry_at: Option, max_retries: u32) -> bool { + if retry_count >= max_retries { + return false; + } + let backoff = Duration::from_secs(5 * 2u64.pow(retry_count.min(4))); + match last_retry_at { + None => true, + Some(t) => t.elapsed() >= backoff, + } +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{Address, B256, U256}; + + use super::*; + + #[test] + fn test_deposit_key_hash_matches_evm_bridge() { + let key = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 0, + }; + let hash = key.hash(); + assert_eq!(hash, key.hash()); + assert_ne!(hash, [0u8; 32]); + } + + #[test] + fn test_deposit_key_different_log_index_different_hash() { + let key1 = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 0, + }; + let key2 = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 1, + }; + assert_ne!(key1.hash(), key2.hash()); + } + + #[tokio::test] + async fn test_monitor_state_track_and_complete() { + let mut state = MonitorState::new(0); + + let key = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 1, + log_index: 0, + }; + state + .track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::from(1000), + nonce: U256::from(0), + }) + .await; + + assert_eq!(state.pending_deposits().len(), 1); + assert_eq!(state.completed_deposits().len(), 0); + + state.complete_deposit(&key).await; + + assert_eq!(state.pending_deposits().len(), 0); + assert_eq!(state.completed_deposits().len(), 1); + } + + #[tokio::test] + async fn test_monitor_state_track_and_forward_burn() { + let mut state = MonitorState::new(0); + + state + .track_burn(PendingBurn { + linera_height: 10, + burn_index: 0, + evm_recipient: "0xabcd".to_string(), + amount: "500".to_string(), + }) + .await; + + assert_eq!(state.pending_burns().len(), 1); + assert_eq!(state.completed_burns().len(), 0); + + state.complete_burn(10, 0).await; + + assert_eq!(state.pending_burns().len(), 0); + assert_eq!(state.completed_burns().len(), 1); + } + + #[tokio::test] + async fn test_status_summary() { + let mut state = MonitorState::new(100); + + let key = DepositKey { + source_chain_id: 1, + block_hash: [0; 32], + tx_index: 0, + log_index: 0, + }; + state + .track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::ZERO, + nonce: U256::ZERO, + }) + .await; + state + .track_burn(PendingBurn { + linera_height: 5, + burn_index: 0, + evm_recipient: "0x1234".to_string(), + amount: "100".to_string(), + }) + .await; + + let summary = state.status_summary(); + assert_eq!(summary.deposits_pending, 1); + assert_eq!(summary.deposits_completed, 0); + assert_eq!(summary.burns_pending, 1); + assert_eq!(summary.burns_forwarded, 0); + assert_eq!(summary.last_scanned_evm_block, 100); + } + + #[test] + fn test_retry_eligible_first_attempt() { + assert!(retry_eligible(0, None, 10)); + } + + #[test] + fn test_retry_eligible_max_retries_exceeded() { + assert!(!retry_eligible(10, None, 10)); + } + + #[test] + fn test_retry_eligible_backoff_not_elapsed() { + let just_now = Instant::now(); + assert!(!retry_eligible(0, Some(just_now), 10)); + } + + #[test] + fn test_retry_eligible_backoff_elapsed() { + let long_ago = Instant::now() - Duration::from_secs(60); + assert!(retry_eligible(0, Some(long_ago), 10)); + } + + #[tokio::test] + async fn test_deposits_ready_for_retry() { + let mut state = MonitorState::new(0); + let key = DepositKey { + source_chain_id: 1, + block_hash: [0; 32], + tx_index: 0, + log_index: 0, + }; + state + .track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::ZERO, + nonce: U256::ZERO, + }) + .await; + + assert_eq!(state.deposits_ready_for_retry(10).len(), 1); + + state.mark_deposit_retried(&key); + assert_eq!(state.deposits_ready_for_retry(10).len(), 0); + + state.mark_deposit_failed(&key).await; + assert_eq!(state.deposits_ready_for_retry(10).len(), 0); + } +} diff --git a/linera-bridge/src/proof/gen.rs b/linera-bridge/src/proof/gen.rs index 26f7fc8b8201..4e0e8ff06c82 100644 --- a/linera-bridge/src/proof/gen.rs +++ b/linera-bridge/src/proof/gen.rs @@ -23,10 +23,21 @@ use alloy::{ providers::{Provider, ProviderBuilder}, }; use alloy_rlp::Encodable; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use async_trait::async_trait; use op_alloy_network::Optimism; +/// Errors from deposit proof generation, classified for retry logic. +#[derive(Debug, thiserror::Error)] +pub enum ProofError { + /// Retrying may succeed (e.g. receipt not yet indexed, RPC transport error). + #[error("transient error: {0:#}")] + Transient(anyhow::Error), + /// Retrying will not help (e.g. hash mismatch, missing deposit event). + #[error("permanent error: {0:#}")] + Permanent(anyhow::Error), +} + /// All data needed to submit a `ProcessDeposit` operation to the evm-bridge app. #[derive(Debug, Clone)] pub struct DepositProof { @@ -52,7 +63,7 @@ pub trait DepositProofClient { /// /// The implementation fetches the receipt, locates the `DepositInitiated` /// event log automatically, and constructs the MPT proof. - async fn generate_deposit_proof(&self, tx_hash: B256) -> Result; + async fn generate_deposit_proof(&self, tx_hash: B256) -> Result; } /// HTTP-based deposit proof client that queries an EVM JSON-RPC endpoint. @@ -79,29 +90,35 @@ impl HttpDepositProofClient { #[async_trait] impl DepositProofClient for HttpDepositProofClient { - async fn generate_deposit_proof(&self, tx_hash: B256) -> Result { + async fn generate_deposit_proof(&self, tx_hash: B256) -> Result { // 1. Get transaction receipt → block hash, tx index let receipt = self .provider .get_transaction_receipt(tx_hash) - .await? - .with_context(|| format!("transaction receipt not found for {tx_hash}"))?; - - let block_hash = receipt - .inner - .block_hash - .context("receipt missing block_hash (pending tx?)")?; - let tx_index = receipt - .inner - .transaction_index - .context("receipt missing transaction_index")?; + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!( + "transaction receipt not found for {tx_hash}" + )) + })?; + + let block_hash = receipt.inner.block_hash.ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("receipt missing block_hash (pending tx?)")) + })?; + let tx_index = receipt.inner.transaction_index.ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("receipt missing transaction_index")) + })?; // 2. Get full block → header RLP let block = self .provider .get_block_by_hash(block_hash) - .await? - .with_context(|| format!("block not found for hash {block_hash}"))?; + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("block not found for hash {block_hash}")) + })?; let mut block_header_rlp = Vec::new(); block.header.inner.encode(&mut block_header_rlp); @@ -109,18 +126,23 @@ impl DepositProofClient for HttpDepositProofClient { // Sanity check: header RLP hashes to the expected block hash let computed_hash = alloy_primitives::keccak256(&block_header_rlp); if computed_hash != block_hash { - bail!( + return Err(ProofError::Permanent(anyhow::anyhow!( "header RLP hash mismatch: computed {computed_hash}, expected {block_hash}. \ This may indicate the RPC returned non-standard header fields." - ); + ))); } // 3. Get all block receipts let all_receipts = self .provider .get_block_receipts(block.header.number.into()) - .await? - .with_context(|| format!("block receipts not found for block {block_hash}"))?; + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!( + "block receipts not found for block {block_hash}" + )) + })?; // 4. Encode each receipt to canonical EIP-2718 form (as stored in the trie). // Convert RPC logs → primitives logs so Encodable2718 is available. @@ -140,28 +162,33 @@ impl DepositProofClient for HttpDepositProofClient { .iter() .find(|(idx, _)| *idx == tx_index) .map(|(_, rlp)| rlp.clone()) - .with_context(|| format!("tx_index {tx_index} not found in block receipts"))?; + .ok_or_else(|| { + ProofError::Permanent(anyhow::anyhow!( + "tx_index {tx_index} not found in block receipts" + )) + })?; let (receipts_root, proof_nodes) = crate::proof::build_receipt_proof(&canonical_receipts, tx_index); // Sanity check: computed receipts root matches block header if receipts_root != block.header.inner.receipts_root { - bail!( + return Err(ProofError::Permanent(anyhow::anyhow!( "receipts root mismatch: computed {receipts_root}, \ header says {}. Receipt encoding may be incorrect.", block.header.inner.receipts_root - ); + ))); } // Find all DepositInitiated log indices from the canonical receipt - let logs = crate::proof::decode_receipt_logs(&receipt_rlp) - .context("failed to decode receipt logs")?; + let logs = + crate::proof::decode_receipt_logs(&receipt_rlp).map_err(ProofError::Permanent)?; let log_indices = crate::proof::find_deposit_log_indices(&logs); - anyhow::ensure!( - !log_indices.is_empty(), - "no DepositInitiated event found in receipt for tx {tx_hash}" - ); + if log_indices.is_empty() { + return Err(ProofError::Permanent(anyhow::anyhow!( + "no DepositInitiated event found in receipt for tx {tx_hash}" + ))); + } Ok(DepositProof { block_header_rlp, diff --git a/linera-bridge/src/proof/mod.rs b/linera-bridge/src/proof/mod.rs index c89688983bb7..83362e066060 100644 --- a/linera-bridge/src/proof/mod.rs +++ b/linera-bridge/src/proof/mod.rs @@ -96,6 +96,32 @@ pub struct DepositEvent { pub nonce: U256, } +/// Replay-protection key for processed deposits. +/// +/// On-chain, only the [`DepositKey::hash`] is stored (32 bytes) rather than the +/// full struct, so the `processed_deposits` SetView uses `[u8; 32]`. +#[derive( + Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize, +)] +pub struct DepositKey { + pub source_chain_id: u64, + pub block_hash: [u8; 32], + pub tx_index: u64, + pub log_index: u64, +} + +impl DepositKey { + /// Deterministic keccak-256 hash of the deposit key fields. + pub fn hash(&self) -> [u8; 32] { + let mut data = [0u8; 56]; + data[0..8].copy_from_slice(&self.source_chain_id.to_le_bytes()); + data[8..40].copy_from_slice(&self.block_hash); + data[40..48].copy_from_slice(&self.tx_index.to_le_bytes()); + data[48..56].copy_from_slice(&self.log_index.to_le_bytes()); + keccak256(data).0 + } +} + /// Returns the keccak256 hash of the `DepositInitiated` event signature. pub fn deposit_event_signature() -> B256 { keccak256(b"DepositInitiated(address,uint256,bytes32,bytes32,bytes32,address,uint256,uint256)") @@ -120,11 +146,12 @@ pub mod testing { use super::ReceiptLog; - /// Builds a minimal RLP-encoded Ethereum block header with the given receipts root. + /// Builds a minimal RLP-encoded Ethereum block header with the given receipts root + /// and block number. /// /// All other header fields are set to zero/default values. This produces a valid /// RLP list that can be decoded by [`super::decode_block_header`]. - pub fn build_test_header(receipts_root: B256) -> Vec { + pub fn build_test_header(receipts_root: B256, block_number: u64) -> Vec { let mut payload = Vec::new(); B256::ZERO.encode(&mut payload); // 0: parentHash B256::ZERO.encode(&mut payload); // 1: ommersHash @@ -134,7 +161,7 @@ pub mod testing { receipts_root.encode(&mut payload); // 5: receiptsRoot Bloom::ZERO.encode(&mut payload); // 6: logsBloom 0u64.encode(&mut payload); // 7: difficulty - 12345u64.encode(&mut payload); // 8: number + block_number.encode(&mut payload); // 8: number 30_000_000u64.encode(&mut payload); // 9: gasLimit 21_000u64.encode(&mut payload); // 10: gasUsed 1_700_000_000u64.encode(&mut payload); // 11: timestamp @@ -606,7 +633,7 @@ mod tests { #[test] fn test_decode_block_header() { let receipts_root = B256::from([0xAB; 32]); - let header_rlp = build_test_header(receipts_root); + let header_rlp = build_test_header(receipts_root, 12345); let (block_hash, decoded_root) = decode_block_header(&header_rlp).unwrap(); diff --git a/linera-bridge/src/relay.rs b/linera-bridge/src/relay.rs deleted file mode 100644 index 8ad8659b551b..000000000000 --- a/linera-bridge/src/relay.rs +++ /dev/null @@ -1,582 +0,0 @@ -// Copyright (c) Zefchain Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -//! Relay server for the EVM↔Linera bridge demo. -//! -//! Responsibilities: -//! - **HTTP**: `POST /deposit` — generates MPT deposit proofs and submits `ProcessDeposit` -//! operations on the bridge chain. -//! - **Linera client**: claims a "bridge chain", listens for `NewIncomingBundle` notifications, -//! processes the inbox, and burns any Address20 credits so the EVM contract can release tokens. -//! - **EVM forwarder**: after processing inbox and burns, BCS-serializes the resulting certificates -//! and calls `FungibleBridge.addBlock(bytes)` on the EVM chain. - -use std::{sync::Arc, time::Duration}; - -use alloy::{ - network::EthereumWallet, primitives::Address, providers::ProviderBuilder, - signers::local::PrivateKeySigner, sol, -}; -use anyhow::{Context as _, Result}; -use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Json, Router}; -use futures::StreamExt as _; -use linera_base::{ - crypto::InMemorySigner, - data_types::Amount, - identifiers::{AccountOwner, ApplicationId}, -}; -use linera_chain::data_types::Transaction; -use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; -use linera_core::{environment::wallet::Memory, worker::Reason}; -use linera_execution::{Message, Operation, WasmRuntime}; -use linera_faucet_client::Faucet; -use linera_storage::DbStorage; -use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; -use tokio::sync::{mpsc, oneshot}; -use tower_http::cors::CorsLayer; - -use crate::proof::gen::{DepositProofClient, HttpDepositProofClient}; - -// ── Alloy ABI for FungibleBridge.addBlock ── - -sol! { - #[sol(rpc)] - interface IFungibleBridge { - function addBlock(bytes calldata data) external; - } -} - -// ── BCS-compatible type matching evm_bridge::BridgeOperation ── - -/// Must match `evm_bridge::BridgeOperation` variant-for-variant for BCS compatibility. -#[derive(serde::Serialize)] -enum BridgeOperation { - ProcessDeposit { - block_header_rlp: Vec, - receipt_rlp: Vec, - proof_nodes: Vec>, - tx_index: u64, - log_index: u64, - }, -} - -// ── Channel types for deposit requests ── - -struct DepositRequest { - proof: crate::proof::gen::DepositProof, - response: oneshot::Sender>, -} - -// ── Shared state for the HTTP server ── - -struct AppState { - proof_client: HttpDepositProofClient, - deposit_tx: mpsc::Sender, -} - -// ── HTTP handlers ── - -#[derive(serde::Deserialize)] -struct DepositHttpRequest { - tx_hash: String, -} - -async fn deposit_handler( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - tracing::info!(tx_hash = %req.tx_hash, "Received deposit request"); - - let tx_hash = match req.tx_hash.parse() { - Ok(h) => h, - Err(_) => { - tracing::error!(tx_hash = %req.tx_hash, "Invalid tx_hash format"); - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "invalid tx_hash"})), - ); - } - }; - - // Retry proof generation — on public testnets the RPC may not have - // indexed the receipt yet when the frontend sends the tx hash. - tracing::info!(%tx_hash, "Generating deposit proof..."); - let mut proof = None; - for attempt in 0..5 { - match state.proof_client.generate_deposit_proof(tx_hash).await { - Ok(p) => { - tracing::info!( - %tx_hash, - tx_index = p.tx_index, - log_count = p.log_indices.len(), - "Deposit proof generated" - ); - proof = Some(p); - break; - } - Err(e) => { - if attempt < 4 { - tracing::warn!( - %tx_hash, attempt, "Deposit proof generation failed, retrying: {e:#}" - ); - tokio::time::sleep(std::time::Duration::from_secs(2 * (attempt + 1))).await; - } else { - tracing::error!(%tx_hash, "Deposit proof generation failed after 5 attempts: {e:#}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e:#}")})), - ); - } - } - } - } - let proof = proof.unwrap(); - - tracing::info!(%tx_hash, "Sending deposit to processing channel..."); - let (resp_tx, resp_rx) = oneshot::channel(); - if state - .deposit_tx - .send(DepositRequest { - proof, - response: resp_tx, - }) - .await - .is_err() - { - tracing::error!(%tx_hash, "Relay deposit channel closed"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "relay channel closed"})), - ); - } - - match resp_rx.await { - Ok(Ok(())) => { - tracing::info!(%tx_hash, "Deposit processed successfully"); - (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))) - } - Ok(Err(e)) => { - tracing::error!(%tx_hash, "Deposit processing failed: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e})), - ) - } - Err(_) => { - tracing::error!(%tx_hash, "Deposit response channel closed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "channel closed"})), - ) - } - } -} - -// ── Helpers ── - -/// Resolve bridge address: use CLI arg if provided, otherwise poll a file. -async fn resolve_bridge_address( - bridge_address: Option<&str>, - bridge_address_file: &str, -) -> Result
{ - if let Some(addr) = bridge_address { - return addr.parse().context("invalid bridge address"); - } - - tracing::info!( - file = bridge_address_file, - "Bridge address not provided, polling file..." - ); - loop { - if let Ok(contents) = tokio::fs::read_to_string(bridge_address_file).await { - let addr_str = contents.trim(); - if !addr_str.is_empty() { - let addr: Address = addr_str.parse().context("invalid bridge address in file")?; - tracing::info!(%addr, "Read bridge address from file"); - return Ok(addr); - } - } - tokio::time::sleep(Duration::from_secs(2)).await; - } -} - -/// Poll a file for the evm-bridge ApplicationId (hex-encoded BCS). -async fn resolve_bridge_app_id(file_path: &str) -> Result { - tracing::info!(file = file_path, "Polling for bridge app ID..."); - loop { - if let Ok(contents) = tokio::fs::read_to_string(file_path).await { - let id_str = contents.trim(); - if !id_str.is_empty() { - let app_id: ApplicationId = - id_str.parse().context("invalid ApplicationId in file")?; - tracing::info!(%app_id, "Read bridge app ID from file"); - return Ok(app_id); - } - } - tokio::time::sleep(Duration::from_secs(2)).await; - } -} - -/// Poll a file for the wrapped-fungible ApplicationId (hex-encoded BCS). -async fn resolve_fungible_app_id(file_path: &str) -> Result { - tracing::info!(file = file_path, "Polling for wrapped-fungible app ID..."); - loop { - if let Ok(contents) = tokio::fs::read_to_string(file_path).await { - let id_str = contents.trim(); - if !id_str.is_empty() { - let app_id: ApplicationId = - id_str.parse().context("invalid ApplicationId in file")?; - tracing::info!(%app_id, "Read wrapped-fungible app ID from file"); - return Ok(app_id); - } - } - tokio::time::sleep(Duration::from_secs(2)).await; - } -} - -/// Extract (owner, amount) from a fungible Credit message if the target is Address20. -/// -/// BCS layout: variant 0 (Credit) + target: AccountOwner + amount: Amount + source: AccountOwner -fn try_parse_credit_to_address20(bytes: &[u8]) -> Option<(AccountOwner, Amount)> { - // Variant 0 = Credit - if bytes.first() != Some(&0) { - return None; - } - #[derive(serde::Deserialize)] - struct Credit { - target: AccountOwner, - amount: Amount, - _source: AccountOwner, - } - let credit: Credit = bcs::from_bytes(&bytes[1..]).ok()?; - if !matches!(credit.target, AccountOwner::Address20(_)) { - return None; - } - Some((credit.target, credit.amount)) -} - -/// BCS-serialize a WrappedFungibleOperation::Burn (variant index 7). -fn serialize_burn_operation(owner: &AccountOwner, amount: &Amount) -> Vec { - let mut buf = vec![7u8]; - buf.extend(bcs::to_bytes(owner).unwrap()); - buf.extend(bcs::to_bytes(amount).unwrap()); - buf -} - -// ── EVM forwarding helper ── - -/// BCS-serialize and forward a certified block to FungibleBridge on EVM. -async fn forward_cert_to_evm( - cert: &impl serde::Serialize, - bridge_addr: Address, - provider: &impl alloy::providers::Provider, -) { - let cert_bytes = match bcs::to_bytes(cert) { - Ok(b) => b, - Err(e) => { - tracing::error!("Failed to BCS-serialize certificate: {e}"); - return; - } - }; - - tracing::info!( - size = cert_bytes.len(), - "Calling addBlock on FungibleBridge..." - ); - - let bridge_contract = IFungibleBridge::new(bridge_addr, provider); - match bridge_contract.addBlock(cert_bytes.into()).send().await { - Ok(pending_tx) => match pending_tx.get_receipt().await { - Ok(receipt) => { - tracing::info!( - tx = ?receipt.transaction_hash, - "addBlock transaction confirmed" - ); - } - Err(e) => tracing::error!("addBlock receipt failed: {e}"), - }, - Err(e) => tracing::error!("addBlock send failed: {e}"), - } -} - -// ── Entry point ── - -pub async fn run( - rpc_url: &str, - faucet_url: &str, - bridge_address: Option<&str>, - bridge_address_file: &str, - bridge_app_id_file: &str, - fungible_app_id_file: &str, - evm_private_key: &str, - port: u16, -) -> Result<()> { - tracing_subscriber::fmt::init(); - - // Tonic pulls in rustls 0.23 which requires an explicit crypto provider. - rustls::crypto::ring::default_provider() - .install_default() - .expect("failed to install rustls crypto provider"); - - tracing::info!("Starting bridge relay server..."); - - // ── 1. Set up Linera client ── - tracing::info!("Connecting to Linera faucet at {faucet_url}..."); - let faucet = Faucet::new(faucet_url.to_string()); - tracing::info!("Fetching genesis config..."); - let genesis_config = faucet.genesis_config().await?; - tracing::info!("Genesis config received"); - - let config = MemoryStoreConfig { - max_stream_queries: 10, - kill_on_drop: true, - }; - tracing::info!("Creating storage..."); - let mut storage = DbStorage::::maybe_create_and_connect( - &config, - "bridge-relay", - Some(WasmRuntime::default()), - ) - .await?; - - tracing::info!("Initializing storage from genesis..."); - genesis_config.initialize_storage(&mut storage).await?; - tracing::info!("Storage initialized"); - - let admin_chain_id = genesis_config.admin_chain_id(); - let mut signer = InMemorySigner::new(None); - - tracing::info!("Creating client context..."); - let mut ctx = ClientContext::new( - storage, - Memory::default(), - signer.clone(), - &Default::default(), - None, - genesis_config, - 10_000, - 10_000, - ) - .await?; - tracing::info!("Client context created"); - - // ── 1b. Sync admin chain from validators ── - tracing::info!(%admin_chain_id, "Syncing admin chain from validators..."); - let committee = faucet.current_committee().await?; - tracing::info!( - validators = committee.validators().into_iter().count(), - "Fetched current committee, downloading chain state..." - ); - let admin_client = ctx.make_chain_client(admin_chain_id).await?; - admin_client - .synchronize_chain_state_from_committee(committee) - .await?; - tracing::info!("Admin chain synced"); - - // ── 2. Claim bridge chain ── - tracing::info!("Claiming bridge chain from faucet..."); - let owner = AccountOwner::from(signer.generate_new()); - let chain_desc = faucet.claim(&owner).await?; - let chain_id = chain_desc.id(); - tracing::info!(%chain_id, %owner, "Chain claimed, extending wallet..."); - ctx.extend_with_chain(chain_desc, Some(owner)).await?; - - tracing::info!("Synchronizing bridge chain from validators..."); - let chain_client = ctx.make_chain_client(chain_id).await?; - chain_client.synchronize_from_validators().await?; - - tracing::info!(%chain_id, "Bridge chain claimed"); - - // Write chain ID and owner to /shared/ if the directory exists (Docker mode). - if let Ok(()) = fs_err::write("/shared/bridge-chain-id", chain_id.to_string()) { - tracing::info!("Wrote bridge chain ID to /shared/bridge-chain-id"); - } - if let Ok(()) = fs_err::write("/shared/relay-owner", owner.to_string()) { - tracing::info!("Wrote relay owner to /shared/relay-owner"); - } - - // ── 3. Set up EVM provider ── - let bridge_addr = resolve_bridge_address(bridge_address, bridge_address_file).await?; - let evm_signer: PrivateKeySigner = - evm_private_key.parse().context("invalid EVM private key")?; - let evm_wallet = EthereumWallet::from(evm_signer); - let provider = ProviderBuilder::new() - .wallet(evm_wallet) - .with_simple_nonce_management() - .connect_http(rpc_url.parse().context("invalid RPC URL")?); - - // ── 4. Resolve app IDs ── - let bridge_app_id = resolve_bridge_app_id(bridge_app_id_file).await?; - let fungible_app_id = resolve_fungible_app_id(fungible_app_id_file).await?; - - // ── 5. Start notification listener ── - let mut notifications = chain_client.subscribe()?; - let (listener, _abort_handle, _) = chain_client.listen().await?; - tokio::spawn(listener); - - // ── 6. Start HTTP server ── - let proof_client = HttpDepositProofClient::new(rpc_url)?; - let (deposit_tx, mut deposit_rx) = mpsc::channel::(16); - let app_state = Arc::new(AppState { - proof_client, - deposit_tx, - }); - - let app = Router::new() - .route("/deposit", post(deposit_handler)) - .layer(CorsLayer::permissive()) - .with_state(app_state); - - let bind_addr = format!("0.0.0.0:{port}"); - tracing::info!("HTTP server listening on {bind_addr}"); - - let listener = tokio::net::TcpListener::bind(&bind_addr).await?; - tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app).await { - tracing::error!("HTTP server error: {e}"); - } - }); - - // ── 7. Main loop: process notifications + deposit requests ── - tracing::info!("Listening for notifications and deposit requests..."); - loop { - tokio::select! { - notification = notifications.next() => { - let notification = match notification { - Some(n) => n, - None => { - tracing::warn!("Notification stream ended, exiting"); - break; - } - }; - - if !matches!(notification.reason, Reason::NewIncomingBundle { .. }) { - continue; - } - - tracing::info!("Received NewIncomingBundle, processing inbox..."); - - if let Err(e) = chain_client.synchronize_from_validators().await { - tracing::error!("Failed to synchronize: {e}"); - continue; - } - - let certs = match chain_client.process_inbox().await { - Ok((certs, _)) => certs, - Err(e) => { - tracing::error!("Failed to process inbox: {e}"); - continue; - } - }; - - if certs.is_empty() { - tracing::info!("No certificates from inbox processing"); - continue; - } - - tracing::info!(count = certs.len(), "Processed inbox certificates"); - for cert in &certs { - forward_cert_to_evm(cert, bridge_addr, &provider).await; - } - - // Scan inbox certs for Credit messages to Address20 and submit Burns. - let mut burn_ops = vec![]; - for cert in &certs { - for txn in &cert.block().body.transactions { - if let Transaction::ReceiveMessages(bundle) = txn { - for posted in &bundle.bundle.messages { - if let Message::User { application_id, bytes } = &posted.message { - if application_id == &fungible_app_id { - if let Some((owner, amount)) = try_parse_credit_to_address20(bytes.as_slice()) { - burn_ops.push(Operation::User { - application_id: fungible_app_id, - bytes: serialize_burn_operation(&owner, &amount), - }); - } - } - } - } - } - } - } - - if !burn_ops.is_empty() { - tracing::info!(count = burn_ops.len(), "Submitting burn operations..."); - if let Err(e) = chain_client.synchronize_from_validators().await { - tracing::error!("Failed to synchronize before burn: {e}"); - continue; - } - match chain_client.execute_operations(burn_ops, vec![]).await { - Ok(linera_core::data_types::ClientOutcome::Committed(cert)) => { - tracing::info!( - height = %cert.block().header.height, - "Burn operations committed" - ); - forward_cert_to_evm(&cert, bridge_addr, &provider).await; - } - Ok(other) => tracing::error!("Burn not committed: {other:?}"), - Err(e) => tracing::error!("Burn submission failed: {e}"), - } - } - } - - Some(deposit_req) = deposit_rx.recv() => { - let result = async { - let proof = &deposit_req.proof; - - let operations: Vec<_> = proof.log_indices.iter().map(|&log_index| { - let op = BridgeOperation::ProcessDeposit { - block_header_rlp: proof.block_header_rlp.clone(), - receipt_rlp: proof.receipt_rlp.clone(), - proof_nodes: proof.proof_nodes.clone(), - tx_index: proof.tx_index, - log_index, - }; - let op_bytes = bcs::to_bytes(&op) - .expect("failed to BCS-serialize BridgeOperation"); - Operation::User { - application_id: bridge_app_id, - bytes: op_bytes, - } - }).collect(); - - tracing::info!( - count = operations.len(), - "Submitting ProcessDeposit operations on bridge chain..." - ); - - chain_client.synchronize_from_validators().await - .context("failed to synchronize")?; - - let outcome = chain_client - .execute_operations(operations, vec![]) - .await?; - let cert = match outcome { - linera_core::data_types::ClientOutcome::Committed(cert) => { - tracing::info!( - height = %cert.block().header.height, - "ProcessDeposit committed" - ); - cert - } - other => { - anyhow::bail!("ProcessDeposit not committed: {other:?}"); - } - }; - - // Forward the deposit block to EVM so the Microchain - // height stays sequential. - forward_cert_to_evm(&cert, bridge_addr, &provider).await; - - Ok::<(), anyhow::Error>(()) - }.await; - - let _ = deposit_req.response.send( - result.map_err(|e| format!("{e:#}")) - ); - } - } - } - - Ok(()) -} diff --git a/linera-bridge/src/relay/evm.rs b/linera-bridge/src/relay/evm.rs new file mode 100644 index 000000000000..c9983b647734 --- /dev/null +++ b/linera-bridge/src/relay/evm.rs @@ -0,0 +1,119 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Centralized EVM client for all bridge EVM interactions. + +use alloy::{ + primitives::{Address, B256}, + providers::Provider, + rpc::types::{Filter, Log}, + sol, +}; +use anyhow::{Context as _, Result}; + +use crate::proof::deposit_event_signature; + +sol! { + #[sol(rpc)] + interface IFungibleBridge { + function addBlock(bytes calldata data) external; + } +} + +/// Must match `evm_bridge::BridgeOperation` variant-for-variant for BCS compatibility. +#[derive(serde::Serialize)] +pub(crate) enum BridgeOperation { + ProcessDeposit { + block_header_rlp: Vec, + receipt_rlp: Vec, + proof_nodes: Vec>, + tx_index: u64, + log_index: u64, + }, +} + +/// Maximum block range per `eth_getLogs` query. +const MAX_LOG_BLOCK_RANGE: u64 = 10_000; + +/// Centralized client for all EVM interactions. Safe to share via `Arc`. +pub struct EvmClient

{ + provider: P, + bridge_addr: Address, + deposit_event_sig: B256, +} + +impl EvmClient

{ + pub fn new(provider: P, bridge_addr: Address) -> Self { + Self { + provider, + bridge_addr, + deposit_event_sig: deposit_event_signature(), + } + } + + pub fn bridge_addr(&self) -> Address { + self.bridge_addr + } + + pub async fn get_block_number(&self) -> Result { + Ok(self.provider.get_block_number().await?) + } + + /// Queries `DepositInitiated` events in chunked ranges. + pub async fn get_deposit_logs(&self, from: u64, to: u64) -> Result> { + let filter_base = Filter::new() + .address(self.bridge_addr) + .event_signature(self.deposit_event_sig); + + let mut all_logs = Vec::new(); + let mut cursor = from; + while cursor <= to { + let chunk_end = (cursor + MAX_LOG_BLOCK_RANGE - 1).min(to); + let filter = filter_base.clone().from_block(cursor).to_block(chunk_end); + let logs = self.provider.get_logs(&filter).await?; + all_logs.extend(logs); + cursor = chunk_end + 1; + } + Ok(all_logs) + } + + /// Queries ERC-20 Transfer events from the bridge to a recipient. + pub async fn get_transfer_logs(&self, recipient: Address) -> Result> { + let transfer_sig = alloy::primitives::keccak256("Transfer(address,address,uint256)"); + let filter = Filter::new() + .address(self.bridge_addr) + .event_signature(transfer_sig) + .topic2(B256::left_padding_from(recipient.as_slice())); + Ok(self.provider.get_logs(&filter).await?) + } + + /// BCS-serialize and forward a certified block to FungibleBridge on EVM. + pub async fn forward_cert( + &self, + cert: &linera_chain::types::ConfirmedBlockCertificate, + ) -> Result<()> { + let cert_bytes = bcs::to_bytes(cert).context("failed to BCS-serialize certificate")?; + + tracing::info!( + size = cert_bytes.len(), + "Calling addBlock on FungibleBridge..." + ); + + let bridge_contract = IFungibleBridge::new(self.bridge_addr, &self.provider); + let pending_tx = bridge_contract + .addBlock(cert_bytes.into()) + .send() + .await + .context("addBlock send failed")?; + let receipt = pending_tx + .get_receipt() + .await + .context("addBlock receipt failed")?; + + tracing::info!( + tx = ?receipt.transaction_hash, + "addBlock transaction confirmed" + ); + Ok(()) + } +} diff --git a/linera-bridge/src/relay/linera.rs b/linera-bridge/src/relay/linera.rs new file mode 100644 index 000000000000..d448f4f17ed8 --- /dev/null +++ b/linera-bridge/src/relay/linera.rs @@ -0,0 +1,213 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Centralized Linera client for all bridge chain interactions. + +use anyhow::Result; +use linera_base::{ + crypto::CryptoHash, + data_types::Amount, + identifiers::{AccountOwner, ApplicationId}, +}; +use linera_chain::types::ConfirmedBlockCertificate; +use linera_core::client::ChainClient; +use tokio::sync::{mpsc, oneshot}; + +use crate::proof::DepositKey; + +/// A write operation to be executed on the bridge chain. +/// Sent to the main loop which serializes all chain mutations. +pub(crate) enum ChainOperation { + ProcessInbox { + response: oneshot::Sender, String>>, + }, + ProcessDeposit { + proof: crate::proof::gen::DepositProof, + response: oneshot::Sender>, + }, + Burn { + owner: AccountOwner, + amount: Amount, + response: oneshot::Sender>, + }, +} + +/// Centralized client for Linera chain interactions. +/// +/// Read operations use the `ChainClient` directly (safe on clones). +/// Write operations (block proposals) go through a channel to the main loop. +pub struct LineraClient { + chain_client: ChainClient, + op_tx: mpsc::Sender, + bridge_app_id: ApplicationId, + fungible_app_id: ApplicationId, +} + +impl LineraClient { + pub(crate) fn new( + chain_client: ChainClient, + op_tx: mpsc::Sender, + bridge_app_id: ApplicationId, + fungible_app_id: ApplicationId, + ) -> Self { + Self { + chain_client, + op_tx, + bridge_app_id, + fungible_app_id, + } + } + + pub fn bridge_app_id(&self) -> ApplicationId { + self.bridge_app_id + } + + pub fn fungible_app_id(&self) -> ApplicationId { + self.fungible_app_id + } + + // ── Read operations (safe on cloned chain_client) ── + + pub async fn sync(&self) -> Result<()> { + self.chain_client + .synchronize_from_validators() + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(()) + } + + pub async fn chain_info(&self) -> Result> { + Ok(self + .chain_client + .chain_info() + .await + .map_err(|e| anyhow::anyhow!(e))?) + } + + pub async fn read_confirmed_block( + &self, + hash: CryptoHash, + ) -> Result { + Ok(self + .chain_client + .read_confirmed_block(hash) + .await + .map_err(|e| anyhow::anyhow!(e))?) + } + + pub async fn read_certificate(&self, hash: CryptoHash) -> Result { + Ok(self + .chain_client + .read_certificate(hash) + .await + .map_err(|e| anyhow::anyhow!(e))?) + } + + pub async fn query_deposit_processed(&self, deposit_key: &DepositKey) -> Result { + crate::monitor::query_deposit_processed(&self.chain_client, self.bridge_app_id, deposit_key) + .await + } + + // ── Write operations (sent to main loop via channel) ── + + pub async fn process_deposit(&self, proof: crate::proof::gen::DepositProof) -> Result<()> { + let (resp_tx, resp_rx) = oneshot::channel(); + self.op_tx + .send(ChainOperation::ProcessDeposit { + proof, + response: resp_tx, + }) + .await + .map_err(|_| anyhow::anyhow!("Chain operation channel closed"))?; + resp_rx + .await + .map_err(|_| anyhow::anyhow!("Response channel closed"))? + .map_err(|e| anyhow::anyhow!(e)) + } + + pub async fn burn( + &self, + owner: AccountOwner, + amount: Amount, + ) -> Result { + let (resp_tx, resp_rx) = oneshot::channel(); + self.op_tx + .send(ChainOperation::Burn { + owner, + amount, + response: resp_tx, + }) + .await + .map_err(|_| anyhow::anyhow!("Chain operation channel closed"))?; + resp_rx + .await + .map_err(|_| anyhow::anyhow!("Response channel closed"))? + .map_err(|e| anyhow::anyhow!(e)) + } + + pub async fn process_inbox(&self) -> Result> { + let (resp_tx, resp_rx) = oneshot::channel(); + self.op_tx + .send(ChainOperation::ProcessInbox { response: resp_tx }) + .await + .map_err(|_| anyhow::anyhow!("Chain operation channel closed"))?; + resp_rx + .await + .map_err(|_| anyhow::anyhow!("Response channel closed"))? + .map_err(|e| anyhow::anyhow!(e)) + } +} + +impl Clone for LineraClient { + fn clone(&self) -> Self { + Self { + chain_client: self.chain_client.clone(), + op_tx: self.op_tx.clone(), + bridge_app_id: self.bridge_app_id, + fungible_app_id: self.fungible_app_id, + } + } +} + +/// Find all Credit-to-Address20 messages in a block's transactions for a given app. +pub(crate) fn find_address20_credits( + transactions: &[linera_chain::data_types::Transaction], + fungible_app_id: ApplicationId, +) -> Vec<(AccountOwner, Amount)> { + let mut credits = Vec::new(); + for txn in transactions { + if let linera_chain::data_types::Transaction::ReceiveMessages(bundle) = txn { + for posted in &bundle.bundle.messages { + if let linera_execution::Message::User { + application_id, + bytes, + } = &posted.message + { + if *application_id == fungible_app_id { + if let Some(credit) = try_parse_credit_to_address20(bytes.as_slice()) { + credits.push(credit); + } + } + } + } + } + } + credits +} + +/// BCS-serialize a Burn operation. +pub(crate) fn serialize_burn_operation(owner: &AccountOwner, amount: &Amount) -> Vec { + bcs::to_bytes(&wrapped_fungible::WrappedFungibleOperation::Burn { + owner: *owner, + amount: *amount, + }) + .expect("failed to BCS-serialize Burn operation") +} + +fn try_parse_credit_to_address20(bytes: &[u8]) -> Option<(AccountOwner, Amount)> { + if let Ok(fungible::Message::Credit { target, amount, .. }) = bcs::from_bytes(bytes) { + matches!(target, AccountOwner::Address20(_)).then_some((target, amount)) + } else { + None + } +} diff --git a/linera-bridge/src/relay/metrics.rs b/linera-bridge/src/relay/metrics.rs new file mode 100644 index 000000000000..bf0e36aac251 --- /dev/null +++ b/linera-bridge/src/relay/metrics.rs @@ -0,0 +1,135 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Prometheus metrics for the bridge relay. + +use std::sync::LazyLock; + +use axum::{http::StatusCode, response::IntoResponse, routing::get, Router}; +use prometheus::{IntCounter, IntGauge, Opts, TextEncoder}; +use tower_http::cors::CorsLayer; + +static DEPOSITS_DETECTED: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_deposits_detected", + "Total deposits found by EVM scanner", + ) + .namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static DEPOSITS_COMPLETED: LazyLock = LazyLock::new(|| { + let opts = + Opts::new("bridge_deposits_completed", "Deposits confirmed on Linera").namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static DEPOSITS_PENDING: LazyLock = LazyLock::new(|| { + let opts = + Opts::new("bridge_deposits_pending", "Currently pending deposits").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static DEPOSITS_FAILED: LazyLock = LazyLock::new(|| { + let opts = + Opts::new("bridge_deposits_failed", "Permanently failed deposits").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static BURNS_DETECTED: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_burns_detected", + "Total burns found by Linera scanner", + ) + .namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static BURNS_COMPLETED: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_burns_completed", "Burns forwarded to EVM").namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static BURNS_PENDING: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_burns_pending", "Currently pending burns").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static BURNS_FAILED: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_burns_failed", "Permanently failed burns").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static LAST_SCANNED_EVM_BLOCK: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_last_scanned_evm_block", + "Last scanned EVM block number", + ) + .namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static LAST_SCANNED_LINERA_HEIGHT: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_last_scanned_linera_height", + "Last scanned Linera block height", + ) + .namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +pub(crate) fn deposit_detected() { + DEPOSITS_DETECTED.inc(); + DEPOSITS_PENDING.inc(); +} + +pub(crate) fn deposit_completed() { + DEPOSITS_COMPLETED.inc(); + DEPOSITS_PENDING.dec(); +} + +pub(crate) fn deposit_failed() { + DEPOSITS_PENDING.dec(); + DEPOSITS_FAILED.inc(); +} + +pub(crate) fn burn_detected() { + BURNS_DETECTED.inc(); + BURNS_PENDING.inc(); +} + +pub(crate) fn burn_completed() { + BURNS_COMPLETED.inc(); + BURNS_PENDING.dec(); +} + +pub(crate) fn burn_failed() { + BURNS_PENDING.dec(); + BURNS_FAILED.inc(); +} + +pub(crate) fn set_last_scanned_evm_block(block: u64) { + LAST_SCANNED_EVM_BLOCK.set(block as i64); +} + +pub(crate) fn set_last_scanned_linera_height(height: u64) { + LAST_SCANNED_LINERA_HEIGHT.set(height as i64); +} + +pub(crate) fn build_router() -> Router { + Router::new() + .route("/metrics", get(serve_metrics)) + .layer(CorsLayer::permissive()) +} + +async fn serve_metrics() -> impl IntoResponse { + let metric_families = prometheus::gather(); + match TextEncoder::new().encode_to_string(&metric_families) { + Ok(text) => (StatusCode::OK, text).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to encode metrics: {e}"), + ) + .into_response(), + } +} diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs new file mode 100644 index 000000000000..b275b98d1ca1 --- /dev/null +++ b/linera-bridge/src/relay/mod.rs @@ -0,0 +1,554 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Relay server for the EVM↔Linera bridge demo. +//! +//! Responsibilities: +//! - **HTTP**: `POST /deposit` — generates MPT deposit proofs and submits `ProcessDeposit` +//! operations on the bridge chain. +//! - **Linera client**: manages a "bridge chain", listens for `NewIncomingBundle` notifications, +//! processes the inbox, and burns any Address20 credits so the EVM contract can release tokens. +//! - **EVM forwarder**: after processing inbox and burns, BCS-serializes the resulting certificates +//! and calls `FungibleBridge.addBlock(bytes)` on the EVM chain. + +use linera_base::crypto::Signer as _; + +pub mod evm; +pub mod linera; +pub(crate) mod metrics; + +use std::{path::Path, sync::Arc, time::Duration}; + +use alloy::{ + network::EthereumWallet, primitives::Address, providers::ProviderBuilder, + signers::local::PrivateKeySigner, +}; +use anyhow::{Context as _, Result}; +use futures::StreamExt as _; +use linera_base::{ + crypto::InMemorySigner, + identifiers::{AccountOwner, ApplicationId, ChainId}, +}; +use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; +use linera_core::{client::ChainClient, wallet::PersistentWallet, worker::Reason}; +use linera_execution::{Operation, WasmRuntime}; +use linera_faucet_client::Faucet; +use linera_persistent::Persist; +use linera_storage::DbStorage; +use linera_views::{ + backends::{ + lru_caching::LruCachingConfig, + rocks_db::{PathWithGuard, RocksDbDatabase, RocksDbSpawnMode, RocksDbStoreInternalConfig}, + }, + lru_prefix_cache::StorageCacheConfig, +}; +use tokio::sync::{mpsc, RwLock}; + +use crate::{ + monitor::{self, MonitorState}, + proof::gen::HttpDepositProofClient, +}; + +#[allow(clippy::too_many_arguments)] +pub async fn run( + rpc_url: &str, + faucet_url: Option<&str>, + wallet_path: Option<&Path>, + keystore_path: Option<&Path>, + storage_config: Option<&str>, + chain_id_arg: Option, + chain_owner_arg: Option, + evm_bridge_address: &str, + linera_bridge_address: &str, + linera_fungible_address: &str, + evm_private_key: &str, + port: u16, + cache_sizes: linera_storage::StorageCacheSizes, + monitor_scan_interval: u64, + monitor_start_block: u64, + max_retries: u32, + sqlite_path: Option<&Path>, +) -> Result<()> { + linera_base::tracing::init("linera-bridge"); + + // Tonic pulls in rustls 0.23 which requires an explicit crypto provider. + rustls::crypto::ring::default_provider() + .install_default() + .expect("failed to install rustls crypto provider"); + + tracing::info!("Starting bridge relay server..."); + + // ── Resolve paths (same defaults as linera binary: ~/.config/linera/) ── + let default_dir = dirs::config_dir() + .context("no config directory on this platform")? + .join("linera"); + let wallet_path = + wallet_path.map_or_else(|| default_dir.join("wallet.json"), |p| p.to_path_buf()); + let keystore_path = + keystore_path.map_or_else(|| default_dir.join("keystore.json"), |p| p.to_path_buf()); + let storage_path = storage_config.map_or_else( + || format!("rocksdb:{}", default_dir.join("wallet.db").display()), + |s| s.to_string(), + ); + + tracing::info!( + wallet = %wallet_path.display(), + keystore = %keystore_path.display(), + storage = %storage_path, + "Resolved paths" + ); + + // ── Common init ── + let faucet = faucet_url.map(|url| { + tracing::info!("Using Linera faucet at {url}"); + Faucet::new(url.to_string()) + }); + + let mut signer: InMemorySigner = + linera_persistent::File::::read(&keystore_path) + .context("failed to read keystore")? + .into_value(); + + // Parse storage path: expect "rocksdb:/path/to/db" + let db_path = storage_path + .strip_prefix("rocksdb:") + .context("storage config must start with 'rocksdb:'")?; + let mut storage = create_rocksdb_storage(Path::new(db_path), cache_sizes).await?; + + // ── Wallet: load existing or create fresh ── + let wallet = if wallet_path.exists() { + tracing::info!("Loading existing wallet from {}", wallet_path.display()); + let wallet = PersistentWallet::read(&wallet_path).context("failed to read wallet")?; + wallet + .genesis_config() + .initialize_storage(&mut storage) + .await?; + wallet + } else { + let faucet = faucet + .as_ref() + .context("--faucet-url is required when no wallet exists")?; + tracing::info!("Creating new wallet at {}", wallet_path.display()); + let genesis_config = faucet.genesis_config().await?; + genesis_config.initialize_storage(&mut storage).await?; + PersistentWallet::create(&wallet_path, genesis_config).context("failed to create wallet")? + }; + + let admin_chain_id = wallet.genesis_config().admin_chain_id(); + let genesis_config = wallet.genesis_config().clone(); + let mut ctx = ClientContext::new( + storage, + wallet, + signer.clone(), + &Default::default(), + None, + genesis_config, + linera_core::worker::DEFAULT_BLOCK_CACHE_SIZE, + linera_core::worker::DEFAULT_EXECUTION_STATE_CACHE_SIZE, + ) + .await?; + + // ── Sync admin chain (always) ── + tracing::info!(%admin_chain_id, "Syncing admin chain from validators..."); + let admin_client = ctx.make_chain_client(admin_chain_id).await?; + admin_client.synchronize_from_validators().await?; + tracing::info!("Admin chain synced"); + + // ── Resolve bridge chain ── + let (chain_id, _owner) = if let Some(cid) = chain_id_arg { + let owner = chain_owner_arg.context( + "--linera-bridge-chain-owner is required when --linera-bridge-chain-id is provided", + )?; + + // Verify the keystore has the signing key for this owner. + anyhow::ensure!( + signer + .contains_key(&owner) + .await + .context("failed to query keystore")?, + "keystore does not contain a key for owner {owner}" + ); + + // Register the chain with the specified owner. + ctx.update_wallet_for_new_chain( + cid, + Some(owner), + linera_base::data_types::Timestamp::default(), + linera_base::data_types::Epoch::ZERO, + ) + .await?; + + // Register for notifications so the listener can connect to validators. + ctx.client + .extend_chain_mode(cid, linera_core::client::ListeningMode::FullChain); + + // Sync from validators. + let chain_client = ctx.make_chain_client(cid).await?; + chain_client.synchronize_from_validators().await?; + + tracing::info!(%cid, %owner, "Using pre-existing chain"); + (cid, owner) + } else { + // Claim from faucet. + let faucet = faucet + .as_ref() + .context("--faucet-url is required when --linera-bridge-chain-id is not provided")?; + tracing::info!("Claiming bridge chain from faucet..."); + let owner = AccountOwner::from(signer.generate_new()); + let chain_desc = faucet.claim(&owner).await?; + let cid = chain_desc.id(); + tracing::info!(%cid, %owner, "Chain claimed, extending wallet..."); + ctx.extend_with_chain(chain_desc, Some(owner)).await?; + + // Save updated keystore (has new key from generate_new). + let mut ks_file = linera_persistent::File::new(&keystore_path, signer.clone())?; + ks_file.persist().await?; + + // Sync bridge chain. + let chain_client = ctx.make_chain_client(cid).await?; + chain_client.synchronize_from_validators().await?; + tracing::info!(%cid, "Bridge chain claimed and synced"); + (cid, owner) + }; + + let chain_client = ctx.make_chain_client(chain_id).await?; + + Box::pin(serve_loop( + chain_client, + rpc_url, + evm_bridge_address, + linera_bridge_address, + linera_fungible_address, + evm_private_key, + port, + monitor_scan_interval, + monitor_start_block, + max_retries, + sqlite_path, + Path::new(db_path), + )) + .await +} + +type RocksDbStorage = DbStorage; + +async fn create_rocksdb_storage( + path: &Path, + cache_sizes: linera_storage::StorageCacheSizes, +) -> Result { + let config = LruCachingConfig { + inner_config: RocksDbStoreInternalConfig { + path_with_guard: PathWithGuard::new(path.to_path_buf()), + spawn_mode: RocksDbSpawnMode::get_spawn_mode_from_runtime(), + max_stream_queries: 10, + }, + storage_cache_config: StorageCacheConfig { + max_cache_size: 10_000_000, + max_value_entry_size: 1_000_000, + max_find_keys_entry_size: 10_000_000, + max_find_key_values_entry_size: 10_000_000, + max_cache_entries: 1000, + max_cache_value_size: 10_000_000, + max_cache_find_keys_size: 10_000_000, + max_cache_find_key_values_size: 10_000_000, + }, + }; + let storage = DbStorage::::maybe_create_and_connect( + &config, + "bridge_relay", + Some(WasmRuntime::default()), + cache_sizes, + ) + .await?; + Ok(storage) +} + +#[allow(clippy::too_many_arguments)] +async fn serve_loop( + chain_client: ChainClient, + rpc_url: &str, + evm_bridge_address: &str, + linera_bridge_address: &str, + linera_fungible_address: &str, + evm_private_key: &str, + port: u16, + monitor_scan_interval: u64, + monitor_start_block: u64, + max_retries: u32, + sqlite_path_override: Option<&Path>, + storage_dir: &Path, +) -> Result<()> { + // ── Set up centralized clients ── + let bridge_addr: Address = evm_bridge_address + .parse() + .context("invalid --evm-bridge-address")?; + let evm_signer: PrivateKeySigner = + evm_private_key.parse().context("invalid EVM private key")?; + let evm_wallet = EthereumWallet::from(evm_signer); + let provider = ProviderBuilder::new() + .wallet(evm_wallet) + .with_simple_nonce_management() + .connect_http(rpc_url.parse().context("invalid RPC URL")?); + + let evm_client = Arc::new(evm::EvmClient::new(provider, bridge_addr)); + + let bridge_app_id: ApplicationId = linera_bridge_address + .parse() + .context("invalid --linera-bridge-address")?; + let fungible_app_id: ApplicationId = linera_fungible_address + .parse() + .context("invalid --linera-fungible-address")?; + + let (op_tx, mut op_rx) = mpsc::channel::(16); + let linera_client = Arc::new(linera::LineraClient::new( + chain_client.clone(), + op_tx, + bridge_app_id, + fungible_app_id, + )); + + // ── Start notification listener ── + let mut notifications = chain_client.subscribe()?; + let (listener, _abort_handle, _) = chain_client.listen().await?; + let chain_listener_handle = tokio::spawn(listener); + + // ── Monitor state + scan/retry ── + let mut monitor_state = MonitorState::new(monitor_start_block); + let default_sqlite_path = storage_dir + .parent() + .unwrap_or(storage_dir) + .join("bridge_relay.sqlite3"); + let sqlite_path = match sqlite_path_override { + Some(p) => p, + None => &default_sqlite_path, + }; + let db = monitor::db::BridgeDb::open(sqlite_path) + .await + .with_context(|| { + format!( + "failed to open SQLite database at {}", + sqlite_path.display() + ) + })?; + tracing::info!(path = %sqlite_path.display(), "Opened bridge relay SQLite database"); + monitor_state.set_db(db); + let monitor = Arc::new(RwLock::new(monitor_state)); + let scan_interval = Duration::from_secs(monitor_scan_interval); + let (pending_deposit_tx, pending_deposit_rx) = + tokio::sync::mpsc::channel::(64); + let (pending_burn_tx, pending_burn_rx) = tokio::sync::mpsc::channel::(64); + + let evm_scan_handle = { + let monitor = Arc::clone(&monitor); + let evm_client = Arc::clone(&evm_client); + let linera_client = Arc::clone(&linera_client); + tokio::spawn(monitor::evm::evm_scan_loop( + monitor, + evm_client, + linera_client, + pending_deposit_tx, + scan_interval, + max_retries, + )) + }; + let linera_scan_handle = { + let monitor = Arc::clone(&monitor); + let evm_client = Arc::clone(&evm_client); + let linera_client = Arc::clone(&linera_client); + tokio::spawn(monitor::linera::linera_scan_loop( + monitor, + evm_client, + linera_client, + pending_burn_tx, + scan_interval, + max_retries, + )) + }; + + let retry_handle = { + let monitor = Arc::clone(&monitor); + let evm_client = Arc::clone(&evm_client); + let linera_client = Arc::clone(&linera_client); + let proof_client = HttpDepositProofClient::new(rpc_url)?; + tokio::spawn(monitor::retry_loop( + monitor, + proof_client, + evm_client, + linera_client, + pending_deposit_rx, + pending_burn_rx, + )) + }; + + let app = metrics::build_router(); + + let bind_addr = format!("0.0.0.0:{port}"); + tracing::info!("HTTP server listening on {bind_addr}"); + + let tcp_listener = tokio::net::TcpListener::bind(&bind_addr).await?; + let http_server_handle = tokio::spawn(async move { + axum::serve(tcp_listener, app) + .await + .context("HTTP server error") + }); + + tracing::info!( + %bridge_addr, + %bridge_app_id, + %fungible_app_id, + "Relay is ready" + ); + + // ── Main loop: process chain operations + notifications ── + tracing::info!("Listening for chain operations and notifications..."); + let mut chain_listener_handle = chain_listener_handle; + let mut evm_scan_handle = evm_scan_handle; + let mut linera_scan_handle = linera_scan_handle; + let mut retry_handle = retry_handle; + let mut http_server_handle = http_server_handle; + loop { + tokio::select! { + result = &mut chain_listener_handle => { + anyhow::bail!("Chain listener exited unexpectedly: {result:?}"); + } + result = &mut evm_scan_handle => { + anyhow::bail!("EVM scan loop exited unexpectedly: {result:?}"); + } + result = &mut linera_scan_handle => { + anyhow::bail!("Linera scan loop exited unexpectedly: {result:?}"); + } + result = &mut retry_handle => { + anyhow::bail!("Retry loop exited unexpectedly: {result:?}"); + } + result = &mut http_server_handle => { + anyhow::bail!("HTTP server exited unexpectedly: {result:?}"); + } + notification = notifications.next() => { + let notification = match notification { + Some(n) => n, + None => { + tracing::warn!("Notification stream ended, exiting"); + break; + } + }; + + if !matches!(notification.reason, Reason::NewIncomingBundle { .. }) { + continue; + } + + tracing::info!("Received NewIncomingBundle, processing inbox..."); + + if let Err(e) = chain_client.synchronize_from_validators().await { + tracing::error!("Failed to synchronize: {e}"); + continue; + } + + let certs = match chain_client.process_inbox().await { + Ok((certs, _)) => certs, + Err(e) => { + tracing::error!("Failed to process inbox: {e}"); + continue; + } + }; + + if certs.is_empty() { + tracing::debug!("No certificates from inbox processing"); + continue; + } + + tracing::info!(count = certs.len(), "Processed inbox certificates"); + } + + Some(op) = op_rx.recv() => { + match op { + linera::ChainOperation::ProcessInbox { response } => { + let result = async { + chain_client.synchronize_from_validators().await + .context("failed to synchronize")?; + let (certs, _) = chain_client.process_inbox().await?; + Ok(certs) + }.await; + let _ = response.send(result.map_err(|e: anyhow::Error| format!("{e:#}"))); + } + linera::ChainOperation::ProcessDeposit { proof, response } => { + let result = async { + let operations: Vec<_> = proof.log_indices.iter().map(|&log_index| { + let op = evm::BridgeOperation::ProcessDeposit { + block_header_rlp: proof.block_header_rlp.clone(), + receipt_rlp: proof.receipt_rlp.clone(), + proof_nodes: proof.proof_nodes.clone(), + tx_index: proof.tx_index, + log_index, + }; + let op_bytes = bcs::to_bytes(&op) + .expect("failed to BCS-serialize BridgeOperation"); + Operation::User { + application_id: bridge_app_id, + bytes: op_bytes, + } + }).collect(); + + tracing::info!( + count = operations.len(), + "Submitting ProcessDeposit operations..." + ); + + chain_client.synchronize_from_validators().await + .context("failed to synchronize")?; + + let outcome = chain_client + .execute_operations(operations, vec![]) + .await?; + match outcome { + linera_core::data_types::ClientOutcome::Committed(cert) => { + tracing::info!( + height = %cert.block().header.height, + "ProcessDeposit committed" + ); + } + other => { + anyhow::bail!("ProcessDeposit not committed: {other:?}"); + } + }; + Ok(()) + }.await; + let _ = response.send(result.map_err(|e: anyhow::Error| format!("{e:#}"))); + } + linera::ChainOperation::Burn { owner, amount, response } => { + let result = async { + let burn_bytes = linera::serialize_burn_operation(&owner, &amount); + let burn_op = Operation::User { + application_id: fungible_app_id, + bytes: burn_bytes, + }; + + tracing::info!("Submitting Burn operation..."); + + chain_client.synchronize_from_validators().await + .context("failed to synchronize")?; + + let outcome = chain_client + .execute_operations(vec![burn_op], vec![]) + .await?; + match outcome { + linera_core::data_types::ClientOutcome::Committed(cert) => { + tracing::info!( + height = %cert.block().header.height, + "Burn operation committed" + ); + Ok(cert) + } + other => { + anyhow::bail!("Burn not committed: {other:?}"); + } + } + }.await; + let _ = response.send(result.map_err(|e: anyhow::Error| format!("{e:#}"))); + } + } + } + } + } + + Ok(()) +} diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 185cc8eacfb5..2d164c2820c7 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -36,11 +36,10 @@ contract FungibleBridge is Microchain { constructor( address _lightClient, bytes32 _chainId, - uint64 _nextExpectedHeight, bytes32 _applicationId, address _token ) - Microchain(_lightClient, _chainId, _nextExpectedHeight) + Microchain(_lightClient, _chainId) { applicationId = _applicationId; token = IERC20(_token); diff --git a/linera-bridge/src/solidity/Microchain.sol b/linera-bridge/src/solidity/Microchain.sol index 21f10a6a9284..5f5226dcb666 100644 --- a/linera-bridge/src/solidity/Microchain.sol +++ b/linera-bridge/src/solidity/Microchain.sol @@ -7,32 +7,27 @@ import "LightClient.sol"; abstract contract Microchain { LightClient public immutable lightClient; bytes32 public immutable chainId; - uint64 public nextExpectedHeight; mapping(bytes32 => bool) public verifiedBlocks; - constructor(address _lightClient, bytes32 _chainId, uint64 _nextExpectedHeight) { + constructor(address _lightClient, bytes32 _chainId) { lightClient = LightClient(_lightClient); chainId = _chainId; - nextExpectedHeight = _nextExpectedHeight; } - /// Verifies a certificate and accepts the block if it matches this chain and - /// the next expected height. + /// Verifies a certificate and accepts the block if it matches this chain. /// - /// Note: this contract does NOT check `previous_block_hash` to link blocks - /// into a hash chain. This is safe because `ConfirmedBlockCertificate` + /// Note: this contract does NOT check `previous_block_hash` or enforce + /// sequential block heights. This is safe because `ConfirmedBlockCertificate` /// implies BFT-finalized canonicality — a quorum of validators signed this /// specific block at this height, so no conflicting block can exist. - /// If this assumption ever changes at the protocol layer, a - /// `previous_block_hash` check should be added here. + /// Blocks can be submitted in any order; the `verifiedBlocks` mapping + /// prevents duplicate processing. function addBlock(bytes calldata data) external { (BridgeTypes.Block memory blockValue, bytes32 signedHash) = lightClient.verifyBlock(data); require(!verifiedBlocks[signedHash], "block already verified"); require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); - require(blockValue.header.height.value == nextExpectedHeight, "block height must be sequential"); - nextExpectedHeight = blockValue.header.height.value + 1; verifiedBlocks[signedHash] = true; _onBlock(blockValue); } diff --git a/linera-bridge/src/test_helpers.rs b/linera-bridge/src/test_helpers.rs index 7195dff7d734..4f7edf9ee1eb 100644 --- a/linera-bridge/src/test_helpers.rs +++ b/linera-bridge/src/test_helpers.rs @@ -125,17 +125,12 @@ pub fn deploy_microchain( deployer: Address, light_client: Address, chain_id: CryptoHash, - next_expected_height: u64, ) -> Address { let test_source = std::fs::read_to_string("tests/solidity/MicrochainTest.sol") .expect("MicrochainTest.sol not found"); let bytecode = compile_contract(&test_source, "MicrochainTest.sol", "MicrochainTest"); - let constructor_args = ( - light_client, - <[u8; 32]>::from(*chain_id.as_bytes()), - next_expected_height, - ) - .abi_encode_params(); + let constructor_args = + (light_client, <[u8; 32]>::from(*chain_id.as_bytes())).abi_encode_params(); let mut deploy_data = bytecode; deploy_data.extend_from_slice(&constructor_args); deploy_contract(db, deployer, deploy_data) @@ -146,7 +141,6 @@ pub fn deploy_fungible_bridge( deployer: Address, light_client: Address, chain_id: CryptoHash, - next_expected_height: u64, application_id: CryptoHash, token: Address, ) -> Address { @@ -158,7 +152,6 @@ pub fn deploy_fungible_bridge( let constructor_args = ( light_client, <[u8; 32]>::from(*chain_id.as_bytes()), - next_expected_height, <[u8; 32]>::from(*application_id.as_bytes()), token, ) diff --git a/linera-bridge/tests/anvil_deposit_proof.rs b/linera-bridge/tests/anvil_deposit_proof.rs index 2a35281fcebe..f5d94a32af40 100644 --- a/linera-bridge/tests/anvil_deposit_proof.rs +++ b/linera-bridge/tests/anvil_deposit_proof.rs @@ -189,7 +189,6 @@ async fn test_deposit_proof_generation() -> Result<(), Box::from(target_chain_id), // chainId - 0u64, // nextExpectedHeight <[u8; 32]>::from(target_application_id), // applicationId token_address, // token ) diff --git a/linera-bridge/tests/e2e/Cargo.lock b/linera-bridge/tests/e2e/Cargo.lock index 7df4bac1819e..3f637e992a91 100644 --- a/linera-bridge/tests/e2e/Cargo.lock +++ b/linera-bridge/tests/e2e/Cargo.lock @@ -2651,6 +2651,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "fungible" version = "0.1.0" @@ -3637,6 +3647,8 @@ dependencies = [ "linera-base", "linera-execution", "op-alloy-network", + "serde", + "thiserror 1.0.69", "tokio", "url", ] @@ -3655,11 +3667,14 @@ dependencies = [ "linera-core", "linera-execution", "linera-faucet-client", + "linera-persistent", "linera-storage", "linera-views", "rand 0.8.5", + "reqwest 0.12.28", "serde", "serde_json", + "tempfile", "testcontainers", "tokio", "tracing", @@ -3667,6 +3682,17 @@ dependencies = [ "wrapped-fungible", ] +[[package]] +name = "linera-cache" +version = "0.15.15" +dependencies = [ + "cfg_aliases", + "linera-base", + "lru 0.15.0", + "prometheus", + "quick_cache", +] + [[package]] name = "linera-chain" version = "0.16.0" @@ -3741,12 +3767,12 @@ dependencies = [ "custom_debug_derive", "futures", "linera-base", + "linera-cache", "linera-chain", "linera-execution", "linera-storage", "linera-version", "linera-views", - "lru 0.15.0", "papaya", "prometheus", "rand 0.8.5", @@ -3823,6 +3849,23 @@ version = "0.45.1-linera.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9198100e9ce61acd3c714a2e61eb19fc5b8e2178dd645e2d9061e61e6e1feef" +[[package]] +name = "linera-persistent" +version = "0.15.15" +dependencies = [ + "cfg-if", + "cfg_aliases", + "derive_more 1.0.0", + "fs-err", + "fs4", + "serde", + "serde_json", + "thiserror 1.0.69", + "thiserror-context", + "tracing", + "trait-variant", +] + [[package]] name = "linera-rpc" version = "0.16.0" @@ -3909,6 +3952,7 @@ dependencies = [ "futures", "itertools 0.14.0", "linera-base", + "linera-cache", "linera-chain", "linera-execution", "linera-views", @@ -3995,9 +4039,9 @@ dependencies = [ [[package]] name = "linera-wasmer" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6652182476826343f0dd1e76a184ad34bcee57650a9c00c77574b993dd30529" +checksum = "6453ccd433866100d587f3db5ffb6c2116ebbcb97891df4197c1038708a551ba" dependencies = [ "bytes", "cfg-if", @@ -4025,9 +4069,9 @@ dependencies = [ [[package]] name = "linera-wasmer-compiler" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4781ce9fc4a892c9a9727f51ec92d19e1c5b54259da21573671aa49211ae80f" +checksum = "37d7e7b04c3cd3b94eccf13a1b57f631a5339dbf719e58618d03f30f52ff75e4" dependencies = [ "backtrace", "bytes", @@ -4056,9 +4100,9 @@ dependencies = [ [[package]] name = "linera-wasmer-compiler-cranelift" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8056c8bff8e1b5cafd21aac59b9009e93b30f35b7baab5592a6f4c7db120b490" +checksum = "5e5bf79646e59839a83c10b4c48456a86909596c37cc380c49c94c2632a9126c" dependencies = [ "cranelift-codegen", "cranelift-entity", @@ -4075,9 +4119,9 @@ dependencies = [ [[package]] name = "linera-wasmer-compiler-singlepass" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3635a86dd98e2c2fd6dd603054f40b8e379f84365a2238cc177d47547a83eebc" +checksum = "9d5ca769ec09d276da3b4580992ec5b96268f3b36b0d05a6fabc7dcb7cd3418a" dependencies = [ "byteorder", "dynasm", @@ -4094,9 +4138,9 @@ dependencies = [ [[package]] name = "linera-wasmer-vm" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27d020717572fdb6222324ec46b10eeb49f6f4a120ee63cf7145f4392f12fd8" +checksum = "22ed6366c2d832e034052b6f037b5afe38efb1e9d8e9ef4b31b778751330e519" dependencies = [ "backtrace", "cc", @@ -4153,6 +4197,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4588,9 +4638,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "papaya" -version = "0.1.9" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb8b9616002a83f9779ea70a2a44364fe804f8b532b96989d0790a34ad76479" +checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" dependencies = [ "equivalent", "seize", @@ -5081,6 +5131,17 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick_cache" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5602,6 +5663,19 @@ dependencies = [ "semver 1.0.27", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5611,7 +5685,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5843,12 +5917,12 @@ dependencies = [ [[package]] name = "seize" -version = "0.4.9" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d84b0c858bdd30cb56f5597f8b3bf702ec23829e652cc636a1e5a7b9de46ae93" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6442,7 +6516,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -8153,7 +8227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/linera-bridge/tests/e2e/Cargo.toml b/linera-bridge/tests/e2e/Cargo.toml index 349fc0db273f..df7f9babb543 100644 --- a/linera-bridge/tests/e2e/Cargo.toml +++ b/linera-bridge/tests/e2e/Cargo.toml @@ -31,11 +31,17 @@ linera-core = { path = "../../../linera-core", default-features = false, feature ] } linera-execution = { path = "../../../linera-execution" } linera-faucet-client = { path = "../../../linera-faucet/client" } +linera-persistent = { path = "../../../linera-persistent", features = ["fs"] } linera-storage = { path = "../../../linera-storage", features = ["wasmer"] } linera-views = { path = "../../../linera-views" } rand = { version = "0.8", default-features = false, features = ["std_rng"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" testcontainers = { version = "0.27", features = ["docker-compose"] } tokio = { version = "1", features = ["full"] } tracing = "0.1" diff --git a/linera-bridge/tests/e2e/src/lib.rs b/linera-bridge/tests/e2e/src/lib.rs index 96ee26cd344e..adc2d047c083 100644 --- a/linera-bridge/tests/e2e/src/lib.rs +++ b/linera-bridge/tests/e2e/src/lib.rs @@ -164,6 +164,33 @@ pub fn parse_deployed_address(output: &str) -> anyhow::Result

{ anyhow::bail!("Could not find 'Deployed to:' in forge output:\n{output}"); } +/// Queries the evm-bridge app to check whether a deposit has been processed. +/// Mirrors `linera_bridge::monitor::query_deposit_processed` for use in tests +/// without enabling the `relay` feature. +pub async fn query_deposit_processed( + chain_client: &linera_core::client::ChainClient, + bridge_app_id: linera_base::identifiers::ApplicationId, + deposit_key: &linera_bridge::proof::DepositKey, +) -> anyhow::Result { + use linera_execution::{Query, QueryResponse}; + + #[derive(serde::Serialize)] + struct GqlRequest { + query: String, + } + + let hash_hex = format!("0x{}", alloy::primitives::hex::encode(deposit_key.hash())); + let gql = format!(r#"{{ isDepositProcessed(hash: "{hash_hex}") }}"#); + let query = Query::user_without_abi(bridge_app_id, &GqlRequest { query: gql })?; + let (outcome, _) = chain_client.query_application(query, None).await?; + let response_bytes = match outcome.response { + QueryResponse::User(bytes) => bytes, + other => anyhow::bail!("unexpected query response: {other:?}"), + }; + let response: serde_json::Value = serde_json::from_slice(&response_bytes)?; + Ok(response["data"]["isDepositProcessed"].as_bool() == Some(true)) +} + /// Starts docker compose stack with pre-cleanup of stale state. pub async fn start_compose(compose_file: &std::path::Path, project_name: &str) -> DockerCompose { let compose_file_str = compose_file diff --git a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs new file mode 100644 index 000000000000..6bc4d572f3c2 --- /dev/null +++ b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs @@ -0,0 +1,479 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end test for both bridge directions with automatic scanning: +//! 1. EVM→Linera: deposit on EVM without calling `/deposit`, relay scanner auto-processes. +//! 2. Linera→EVM: transfer tokens to Address20 cross-chain, relay detects burn and forwards. +//! +//! Chain layout: +//! - Chain A (relay): evm-bridge + wrapped-fungible apps, operated exclusively by the relay +//! - Chain B (user): test operates here, never touches chain A directly +//! +//! Deploy order (same as setup.sh): +//! 1. MockERC20 +//! 2. wrapped-fungible app (Linera) +//! 3. FungibleBridge with real applicationId (EVM) +//! 4. evm-bridge app with bridge address (Linera) + +use std::{collections::BTreeMap, path::PathBuf, time::Duration}; + +use alloy::{ + network::EthereumWallet, + primitives::{FixedBytes, U256}, + providers::ProviderBuilder, + signers::local::PrivateKeySigner, + sol, +}; +use anyhow::Context as _; +use linera_base::{ + crypto::InMemorySigner, + data_types::{Amount, Bytecode}, + identifiers::{AccountOwner, ApplicationId}, + vm::VmRuntime, +}; +use linera_bridge_e2e::{ + compose_file_path, exec_ok, exec_output, light_client_address, parse_deployed_address, + start_compose, ANVIL_PRIVATE_KEY, +}; +use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; +use linera_core::environment::wallet::Memory; +use linera_execution::{Operation, WasmRuntime}; +use linera_faucet_client::Faucet; +use linera_storage::{DbStorage, StorageCacheSizes}; +use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; +use serde::Serialize; +use wrapped_fungible::{Account, InitialState, WrappedFungibleOperation, WrappedParameters}; + +// ── Inline evm-bridge types ── + +#[derive(Clone, Debug, serde::Deserialize, Serialize)] +struct BridgeParameters { + source_chain_id: u64, + bridge_contract_address: [u8; 20], + fungible_app_id: ApplicationId, + token_address: [u8; 20], + rpc_endpoint: String, +} + +sol! { + #[sol(rpc)] + interface IERC20 { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + } + + #[sol(rpc)] + interface IFungibleBridge { + function deposit( + bytes32 target_chain_id, + bytes32 target_application_id, + bytes32 target_account_owner, + uint256 amount + ) external; + } +} + +#[tokio::test] +#[ignore] // Requires pre-built docker images, Wasm, and relay binary +async fn test_auto_deposit_scan() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_test_writer().try_init().ok(); + let compose_file = compose_file_path(); + let project_name = "linera-auto-scan-test"; + + // ── Phase 1: Start docker compose stack ── + let compose = start_compose(&compose_file, project_name).await; + + // ── Phase 2: Create Linera client, claim chains ── + tracing::info!("Creating programmatic Linera client..."); + let faucet = Faucet::new("http://localhost:8080".to_string()); + let genesis_config = faucet.genesis_config().await?; + + let config = MemoryStoreConfig { + max_stream_queries: 10, + kill_on_drop: true, + }; + let mut storage = DbStorage::::maybe_create_and_connect( + &config, + "auto-scan-e2e-test", + Some(WasmRuntime::default()), + StorageCacheSizes { + blob_cache_size: 1000, + confirmed_block_cache_size: 1000, + lite_certificate_cache_size: 1000, + certificate_raw_cache_size: 1000, + event_cache_size: 1000, + }, + ) + .await?; + + genesis_config.initialize_storage(&mut storage).await?; + let mut signer = InMemorySigner::new(None); + + let mut ctx = ClientContext::new( + storage, + Memory::default(), + signer.clone(), + &Default::default(), + None, + genesis_config, + linera_core::worker::DEFAULT_BLOCK_CACHE_SIZE, + linera_core::worker::DEFAULT_EXECUTION_STATE_CACHE_SIZE, + ) + .await?; + + // Chain A: relay chain. + tracing::info!("Claiming chain A (relay)..."); + let owner_a = AccountOwner::from(signer.generate_new()); + let chain_a_desc = faucet.claim(&owner_a).await?; + let chain_a = chain_a_desc.id(); + ctx.extend_with_chain(chain_a_desc, Some(owner_a)).await?; + let cc_a = ctx.make_chain_client(chain_a).await?; + cc_a.synchronize_from_validators().await?; + tracing::info!(%chain_a, "Chain A claimed"); + + // Chain B: user chain. + tracing::info!("Claiming chain B (user)..."); + let owner_b = AccountOwner::from(signer.generate_new()); + let chain_b_desc = faucet.claim(&owner_b).await?; + let chain_b = chain_b_desc.id(); + ctx.extend_with_chain(chain_b_desc, Some(owner_b)).await?; + let cc_b = ctx.make_chain_client(chain_b).await?; + cc_b.synchronize_from_validators().await?; + tracing::info!(%chain_b, "Chain B claimed"); + + // ── Phase 3: Deploy MockERC20 ── + tracing::info!("Deploying MockERC20..."); + let erc20_output = exec_output( + &compose, + "foundry-tools", + &format!( + "forge create /contracts/MockERC20.sol:MockERC20 \ + --root /contracts --via-ir --optimize \ + --evm-version shanghai \ + --out /tmp/forge-out --cache-path /tmp/forge-cache \ + --rpc-url http://anvil:8545 \ + --broadcast \ + --private-key {ANVIL_PRIVATE_KEY} \ + --constructor-args \"TestToken\" \"TT\" 1000000000000000000000" + ), + project_name, + &compose_file, + ) + .await; + let erc20_addr = parse_deployed_address(&erc20_output)?; + tracing::info!(%erc20_addr, "MockERC20 deployed"); + + // ── Phase 4: Deploy wrapped-fungible app ── + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .context("manifest dir has fewer than 3 ancestors")? + .to_path_buf(); + let wasm_dir = repo_root.join("examples/target/wasm32-unknown-unknown/release"); + + tracing::info!("Publishing wrapped-fungible module..."); + let wf_contract = Bytecode::load_from_file(wasm_dir.join("wrapped_fungible_contract.wasm"))?; + let wf_service = Bytecode::load_from_file(wasm_dir.join("wrapped_fungible_service.wasm"))?; + let (wf_module_id, _) = cc_a + .publish_module(wf_contract, wf_service, VmRuntime::Wasm) + .await? + .expect("publish wrapped-fungible module committed"); + cc_a.synchronize_from_validators().await?; + cc_a.process_inbox().await?; + + tracing::info!("Creating wrapped-fungible application..."); + let (fungible_app_id, _) = cc_a + .create_application_untyped( + wf_module_id, + serde_json::to_vec(&WrappedParameters { + ticker_symbol: "wTEST".to_string(), + minter: owner_a, + mint_chain_id: chain_a, + evm_token_address: erc20_addr.0 .0, + evm_source_chain_id: 31337, + })?, + serde_json::to_vec(&InitialState { + accounts: BTreeMap::new(), + })?, + vec![], + ) + .await? + .expect("create wrapped-fungible app committed"); + tracing::info!(%fungible_app_id, "wrapped-fungible app created"); + + // ── Phase 5: Deploy FungibleBridge with real applicationId ── + let chain_a_bytes32 = format!("0x{chain_a}"); + let app_id_bytes32 = format!("0x{}", fungible_app_id.application_description_hash); + let light_client = light_client_address(); + + tracing::info!("Deploying FungibleBridge..."); + let bridge_output = exec_output( + &compose, + "foundry-tools", + &format!( + "forge create /contracts/FungibleBridge.sol:FungibleBridge \ + --root /contracts --via-ir --optimize \ + --ignored-error-codes 6321 \ + --evm-version shanghai \ + --out /tmp/forge-out --cache-path /tmp/forge-cache \ + --rpc-url http://anvil:8545 \ + --private-key {ANVIL_PRIVATE_KEY} \ + --broadcast \ + --constructor-args \ + {light_client} \ + {chain_a_bytes32} \ + {app_id_bytes32} \ + {erc20_addr}" + ), + project_name, + &compose_file, + ) + .await; + let bridge_addr = parse_deployed_address(&bridge_output)?; + tracing::info!(%bridge_addr, "FungibleBridge deployed"); + + tracing::info!("Funding FungibleBridge with ERC20 tokens..."); + exec_ok( + &compose, + "foundry-tools", + &format!( + "cast send --rpc-url http://anvil:8545 \ + --private-key {ANVIL_PRIVATE_KEY} \ + {erc20_addr} \ + 'transfer(address,uint256)(bool)' \ + {bridge_addr} \ + 500000000000000000000" + ), + project_name, + &compose_file, + ) + .await; + + // ── Phase 6: Deploy evm-bridge app with bridge address ── + tracing::info!("Publishing evm-bridge module..."); + let eb_contract = Bytecode::load_from_file(wasm_dir.join("evm_bridge_contract.wasm"))?; + let eb_service = Bytecode::load_from_file(wasm_dir.join("evm_bridge_service.wasm"))?; + let (eb_module_id, _) = cc_a + .publish_module(eb_contract, eb_service, VmRuntime::Wasm) + .await? + .expect("publish evm-bridge module committed"); + cc_a.synchronize_from_validators().await?; + cc_a.process_inbox().await?; + + tracing::info!("Creating evm-bridge application..."); + let (bridge_app_id, _) = cc_a + .create_application_untyped( + eb_module_id, + serde_json::to_vec(&BridgeParameters { + source_chain_id: 31337, + bridge_contract_address: bridge_addr.0 .0, + fungible_app_id, + token_address: erc20_addr.0 .0, + rpc_endpoint: String::new(), + })?, + serde_json::to_vec(&())?, + vec![fungible_app_id], + ) + .await? + .expect("create evm-bridge app committed"); + tracing::info!(%bridge_app_id, "evm-bridge app created"); + + // ── Phase 7: Start relay ── + let rpc_url = "http://localhost:8545".parse()?; + let evm_signer: PrivateKeySigner = ANVIL_PRIVATE_KEY.parse()?; + let evm_wallet = EthereumWallet::from(evm_signer); + let provider = ProviderBuilder::new() + .wallet(evm_wallet) + .connect_http(rpc_url); + + let relay_binary = repo_root.join("target/debug/linera-bridge"); + anyhow::ensure!( + relay_binary.exists(), + "Relay binary not found at {relay_binary:?}. \ + Run: cargo build -p linera-bridge --features relay" + ); + + let relay_dir = tempfile::tempdir()?; + let wallet_path = relay_dir.path().join("wallet.json"); + let keystore_path = relay_dir.path().join("keystore.json"); + let storage_path = format!("rocksdb:{}", relay_dir.path().join("client.db").display()); + + { + use linera_persistent::Persist; + let mut ks = linera_persistent::File::new(&keystore_path, signer.clone())?; + ks.persist().await?; + } + + // The relay creates its own chain_client for chain A. + // We keep cc_a alive for diagnostics but don't create blocks on it. + + let relay_port = 3002; + tracing::info!("Starting relay binary..."); + let mut relay_process = tokio::process::Command::new(&relay_binary) + .args([ + "serve", + "--rpc-url", "http://localhost:8545", + "--faucet-url", "http://localhost:8080", + "--wallet", wallet_path.to_str().unwrap(), + "--keystore", keystore_path.to_str().unwrap(), + "--storage", &storage_path, + &format!("--linera-bridge-chain-id={chain_a}"), + &format!("--linera-bridge-chain-owner={owner_a}"), + &format!("--evm-bridge-address={bridge_addr}"), + &format!("--linera-bridge-address={bridge_app_id}"), + &format!("--linera-fungible-address={fungible_app_id}"), + &format!("--evm-private-key={ANVIL_PRIVATE_KEY}"), + &format!("--port={relay_port}"), + "--monitor-scan-interval", "5", + "--max-retries", "5", + ]) + .env("RUST_LOG", "linera=info,linera_bridge=debug") + .kill_on_drop(true) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .context("failed to spawn relay binary")?; + + let relay_url = format!("http://localhost:{relay_port}"); + let client = reqwest::Client::new(); + for attempt in 0..30 { + tokio::time::sleep(Duration::from_secs(2)).await; + if client.get(format!("{relay_url}/metrics")).send().await.is_ok() { + tracing::info!(attempt, "Relay is ready"); + break; + } + if attempt == 29 { + relay_process.kill().await.ok(); + anyhow::bail!("Relay did not become ready"); + } + } + + // Diagnostic: check chain A height from test's perspective. + cc_a.synchronize_from_validators().await?; + let info = cc_a.chain_info().await?; + tracing::info!(next_block_height = ?info.next_block_height, "Chain A height from test client"); + + // ══════════════════════════════════════════════════════════════════ + // Phase 8: EVM→Linera deposit targeting chain B + // ══════════════════════════════════════════════════════════════════ + let deposit_amount = U256::from(50u128 * 10u128.pow(18)); + + tracing::info!("Approving FungibleBridge..."); + let erc20_contract = IERC20::new(erc20_addr, &provider); + erc20_contract + .approve(bridge_addr, deposit_amount) + .send() + .await? + .get_receipt() + .await?; + + let chain_b_b256 = { + let bytes: [u8; 32] = chain_b.0.into(); + FixedBytes::<32>::from(bytes) + }; + let owner_b_b256 = match owner_b { + AccountOwner::Address32(hash) => { + let bytes: [u8; 32] = hash.into(); + FixedBytes::<32>::from(bytes) + } + _ => anyhow::bail!("expected Address32 owner"), + }; + + tracing::info!("Depositing on EVM targeting chain B..."); + let bridge_contract = IFungibleBridge::new(bridge_addr, &provider); + let deposit_receipt = bridge_contract + .deposit(chain_b_b256, app_id_bytes32.parse()?, owner_b_b256, deposit_amount) + .send() + .await? + .get_receipt() + .await?; + tracing::info!("Deposit confirmed on EVM"); + + let deposit_key = linera_bridge::proof::DepositKey { + source_chain_id: 31337, + block_hash: deposit_receipt.block_hash.unwrap().0, + tx_index: 0, + log_index: 0, + }; + + // Wait for relay to auto-process the deposit. + tracing::info!("Waiting for relay scanner to auto-process the deposit..."); + for attempt in 0..60 { + tokio::time::sleep(Duration::from_secs(5)).await; + + // Sync chain B to receive minted tokens. + cc_b.synchronize_from_validators().await?; + cc_b.process_inbox().await?; + + // Check on-chain whether the deposit was processed. + match linera_bridge_e2e::query_deposit_processed(&cc_a, bridge_app_id, &deposit_key).await + { + Ok(true) => { + tracing::info!(attempt, "Deposit auto-processed!"); + break; + } + Ok(false) => { + tracing::info!(attempt, "Deposit not yet processed, waiting..."); + } + Err(e) => { + tracing::warn!(attempt, "Deposit query failed: {e:#}"); + } + } + if attempt == 59 { + relay_process.kill().await.ok(); + anyhow::bail!("Deposit not auto-processed within timeout"); + } + } + + // ══════════════════════════════════════════════════════════════════ + // Phase 9: Linera→EVM burn via cross-chain transfer + // ══════════════════════════════════════════════════════════════════ + let evm_recipient = "70997970C51812dc3A010C7d01b50e0d17dc79C8"; + let receiver: AccountOwner = format!("0x{evm_recipient}").parse()?; + let withdraw_amount = Amount::from_tokens(25); + + tracing::info!("Sending cross-chain withdrawal from chain B to Address20 on chain A..."); + cc_b.synchronize_from_validators().await?; + let withdraw_bytes = bcs::to_bytes(&WrappedFungibleOperation::Transfer { + owner: owner_b, + amount: withdraw_amount, + target_account: Account { + chain_id: chain_a, + owner: receiver, + }, + })?; + cc_b.execute_operations( + vec![Operation::User { + application_id: fungible_app_id, + bytes: withdraw_bytes, + }], + vec![], + ) + .await? + .expect("withdrawal committed"); + tracing::info!("Cross-chain withdrawal committed on chain B"); + + // Wait for relay to burn and forward to EVM. + tracing::info!("Waiting for ERC-20 balance..."); + let evm_recipient_addr: alloy::primitives::Address = + format!("0x{evm_recipient}").parse()?; + let expected_balance = U256::from(25u128 * 10u128.pow(18)); + + for attempt in 0..60 { + tokio::time::sleep(Duration::from_secs(5)).await; + + let balance = erc20_contract.balanceOf(evm_recipient_addr).call().await?; + tracing::info!(attempt, ?balance, "ERC-20 balance"); + + if balance >= expected_balance { + relay_process.kill().await.ok(); + tracing::info!( + "Test passed! Both directions: EVM→Linera deposit + Linera→EVM burn." + ); + return Ok(()); + } + } + + relay_process.kill().await.ok(); + anyhow::bail!("Burn not forwarded to EVM within timeout"); +} diff --git a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs index 5f364c089220..dbab73f8b5af 100644 --- a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs +++ b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs @@ -37,7 +37,9 @@ use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; use serde::{Deserialize, Serialize}; use wrapped_fungible::{InitialState, WrappedParameters}; -// ── Inline evm-bridge types (avoids depending on evm-bridge crate) ────────── +// ── Inline evm-bridge types ───────────────────────────────────────────────── +// Inlined to avoid a dependency on evm-bridge, which pulls in linera-bridge +// with the `chain` feature — that disables `proof::gen` via feature unification. /// Must match `evm_bridge::BridgeParameters` field-for-field for BCS compatibility. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -46,6 +48,7 @@ struct BridgeParameters { bridge_contract_address: [u8; 20], fungible_app_id: ApplicationId, token_address: [u8; 20], + rpc_endpoint: String, } /// Must match `evm_bridge::BridgeOperation` variant-for-variant for BCS compatibility. @@ -58,6 +61,9 @@ enum BridgeOperation { tx_index: u64, log_index: u64, }, + VerifyBlockHash { + block_hash: [u8; 32], + }, } // ── Solidity interfaces for EVM calls ─────────────────────────────────────── @@ -173,7 +179,6 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { --constructor-args \ {light_client} \ {chain_id_bytes32} \ - 0 \ {zero_bytes32} \ {erc20_addr}" ), @@ -246,6 +251,7 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { bridge_contract_address: bridge_addr.0 .0, fungible_app_id, token_address: erc20_addr.0 .0, + rpc_endpoint: String::new(), }; let (bridge_app_id, _) = cc .create_application_untyped( @@ -312,14 +318,31 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { "Deposit proof generated" ); - // ── Phase 7: Submit ProcessDeposit on Linera ── + // Build the DepositKey for completion checks. + let tx_index = proof.tx_index; + let log_index = proof.log_indices[0]; + let deposit_key = linera_bridge::proof::DepositKey { + source_chain_id: 31337, // Anvil chain ID + block_hash: deposit_receipt.block_hash.unwrap().0, + tx_index, + log_index, + }; + + // ── Phase 7a: Verify deposit is NOT yet processed ── + assert!( + !linera_bridge_e2e::query_deposit_processed(&cc, bridge_app_id, &deposit_key).await?, + "deposit should NOT be processed before ProcessDeposit" + ); + tracing::info!("Confirmed: deposit not yet processed."); + + // ── Phase 7b: Submit ProcessDeposit on Linera ── tracing::info!("Submitting ProcessDeposit operation..."); let bridge_op = BridgeOperation::ProcessDeposit { block_header_rlp: proof.block_header_rlp, receipt_rlp: proof.receipt_rlp, proof_nodes: proof.proof_nodes, - tx_index: proof.tx_index, - log_index: proof.log_indices[0], + tx_index, + log_index, }; let op_bytes = bcs::to_bytes(&bridge_op)?; let op = Operation::User { @@ -367,6 +390,13 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { "wrapped-fungible balance should match the 100-token deposit" ); - tracing::info!(%balance, "Test passed! Wrapped-fungible balance matches deposit."); + tracing::info!(%balance, "Wrapped-fungible balance matches deposit."); + + // ── Phase 9: Verify deposit IS now processed ── + assert!( + linera_bridge_e2e::query_deposit_processed(&cc, bridge_app_id, &deposit_key).await?, + "deposit should be marked as processed after ProcessDeposit" + ); + tracing::info!("Test passed! Deposit confirmed as processed via GraphQL query."); Ok(()) } diff --git a/linera-bridge/tests/e2e/tests/fungible_bridge.rs b/linera-bridge/tests/e2e/tests/fungible_bridge.rs index b84c08751a10..ac19e1116747 100644 --- a/linera-bridge/tests/e2e/tests/fungible_bridge.rs +++ b/linera-bridge/tests/e2e/tests/fungible_bridge.rs @@ -218,7 +218,6 @@ async fn test_fungible_bridge_transfers_to_evm() -> anyhow::Result<()> { --constructor-args \ {light_client} \ {chain_a_bytes32} \ - 0 \ {app_id_bytes32} \ {erc20_addr}" ), diff --git a/linera-bridge/tests/solidity/MicrochainTest.sol b/linera-bridge/tests/solidity/MicrochainTest.sol index 2babb5a05629..874f6937417a 100644 --- a/linera-bridge/tests/solidity/MicrochainTest.sol +++ b/linera-bridge/tests/solidity/MicrochainTest.sol @@ -6,8 +6,8 @@ import "Microchain.sol"; contract MicrochainTest is Microchain { uint64 public blockCount; - constructor(address _lightClient, bytes32 _chainId, uint64 _nextExpectedHeight) - Microchain(_lightClient, _chainId, _nextExpectedHeight) + constructor(address _lightClient, bytes32 _chainId) + Microchain(_lightClient, _chainId) {} function _onBlock(BridgeTypes.Block memory) internal override { diff --git a/linera-ethereum/src/client.rs b/linera-ethereum/src/client.rs index cc577213fc23..1f1f8ac51161 100644 --- a/linera-ethereum/src/client.rs +++ b/linera-ethereum/src/client.rs @@ -7,7 +7,7 @@ use alloy::rpc::types::eth::{ request::{TransactionInput, TransactionRequest}, BlockId, BlockNumberOrTag, Filter, Log, }; -use alloy_primitives::{Address, Bytes, U256, U64}; +use alloy_primitives::{Address, Bytes, B256, U256, U64}; use async_trait::async_trait; use linera_base::ensure; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -121,6 +121,16 @@ pub trait EthereumQueries { from: &str, block: u64, ) -> Result; + + /// Returns the chain ID reported by the connected EVM node. + async fn get_chain_id(&self) -> Result; + + /// Checks whether a block hash is finalized on the EVM chain. + /// + /// Queries the node for the block (proving it exists), then compares its number + /// against the latest finalized block number. + /// Returns `Err(BlockNotFound)` if the hash does not exist on chain. + async fn is_block_hash_finalized(&self, block_hash: B256) -> Result; } pub(crate) fn get_block_id(block_number: u64) -> BlockId { @@ -149,6 +159,11 @@ where Ok(result.to::()) } + async fn get_chain_id(&self) -> Result { + let result = self.request::<_, U64>("eth_chainId", ()).await?; + Ok(result.to::()) + } + async fn get_balance(&self, address: &str, block_number: u64) -> Result { let address = address.parse::
()?; let tag = get_block_id(block_number); @@ -195,4 +210,25 @@ where let tag = get_block_id(block); Ok(self.request::<_, Bytes>("eth_call", (tx, tag)).await?) } + + async fn is_block_hash_finalized(&self, block_hash: B256) -> Result { + let block: Option = self + .request("eth_getBlockByHash", (block_hash, false)) + .await?; + let block = block.ok_or(EthereumServiceError::BlockNotFound)?; + let block_number = block.number.to::(); + + let finalized: EthBlockNumber = self + .request("eth_getBlockByNumber", ("finalized", false)) + .await?; + let finalized_number = finalized.number.to::(); + + Ok(block_number <= finalized_number) + } +} + +/// Minimal block response for extracting just the block number. +#[derive(Deserialize)] +struct EthBlockNumber { + number: U64, } diff --git a/linera-ethereum/src/common.rs b/linera-ethereum/src/common.rs index 38cfd67d3608..98fcd0c4270f 100644 --- a/linera-ethereum/src/common.rs +++ b/linera-ethereum/src/common.rs @@ -55,6 +55,10 @@ pub enum EthereumServiceError { #[error(transparent)] FromHexError(#[from] alloy_primitives::hex::FromHexError), + /// Block not found on chain + #[error("Block not found on chain")] + BlockNotFound, + /// `serde_json` error #[error(transparent)] JsonError(#[from] serde_json::Error), diff --git a/linera-ethereum/tests/ethereum_test.rs b/linera-ethereum/tests/ethereum_test.rs index a3c1e3d16319..81cec250c407 100644 --- a/linera-ethereum/tests/ethereum_test.rs +++ b/linera-ethereum/tests/ethereum_test.rs @@ -13,6 +13,16 @@ use { std::{collections::BTreeSet, str::FromStr}, }; +#[cfg(feature = "ethereum")] +#[tokio::test] +async fn test_get_chain_id() -> anyhow::Result<()> { + let anvil_test = get_anvil().await?; + let ethereum_client_simp = EthereumClientSimplified::new(anvil_test.endpoint); + let chain_id = ethereum_client_simp.get_chain_id().await?; + assert_eq!(chain_id, 31337, "Anvil default chain ID should be 31337"); + Ok(()) +} + #[cfg(feature = "ethereum")] #[tokio::test] async fn test_get_accounts_balance() -> anyhow::Result<()> { diff --git a/linera-metrics/src/monitoring_server.rs b/linera-metrics/src/monitoring_server.rs index ac4ee60f0af7..8795fb3b05e9 100644 --- a/linera-metrics/src/monitoring_server.rs +++ b/linera-metrics/src/monitoring_server.rs @@ -81,7 +81,20 @@ pub fn start_metrics( shutdown_signal: CancellationToken, memory_profiling: MemoryProfiling, ) { - let app = metrics_router(memory_profiling); + start_metrics_with_extras(address, shutdown_signal, memory_profiling, None); +} + +pub fn start_metrics_with_extras( + address: impl ToSocketAddrs + Debug + Send + 'static, + shutdown_signal: CancellationToken, + memory_profiling: MemoryProfiling, + extra_routes: Option, +) { + let mut app = metrics_router(memory_profiling); + + if let Some(extra) = extra_routes { + app = app.merge(extra); + } tokio::spawn(async move { let listener = tokio::net::TcpListener::bind(address) diff --git a/linera-sdk/src/test/chain.rs b/linera-sdk/src/test/chain.rs index 7839e3dada67..80887bfbdc28 100644 --- a/linera-sdk/src/test/chain.rs +++ b/linera-sdk/src/test/chain.rs @@ -573,6 +573,49 @@ impl ActiveChain { ApplicationId::<()>::from(&description).with_abi() } + /// Fallible version of [`create_application`](Self::create_application). + /// + /// Returns the [`ApplicationId`] on success, or a [`WorkerError`] if instantiation fails. + pub async fn try_create_application( + &mut self, + module_id: ModuleId, + parameters: Parameters, + instantiation_argument: InstantiationArgument, + required_application_ids: Vec, + ) -> Result, WorkerError> + where + Abi: ContractAbi, + Parameters: Serialize, + InstantiationArgument: Serialize, + { + let parameters = serde_json::to_vec(¶meters).unwrap(); + let instantiation_argument = serde_json::to_vec(&instantiation_argument).unwrap(); + + let (creation_certificate, _) = self + .try_add_block(|block| { + block.with_system_operation(SystemOperation::CreateApplication { + module_id: module_id.forget_abi(), + parameters: parameters.clone(), + instantiation_argument, + required_application_ids: required_application_ids.clone(), + }); + }) + .await?; + + let block = creation_certificate.inner().block(); + + let description = ApplicationDescription { + module_id: module_id.forget_abi(), + creator_chain_id: block.header.chain_id, + block_height: block.header.height, + application_index: 0, + parameters, + required_application_ids, + }; + + Ok(ApplicationId::<()>::from(&description).with_abi()) + } + /// Returns whether this chain has been closed. pub async fn is_closed(&self) -> bool { let chain = Box::pin(self.validator.worker().chain_state_view(self.id())) diff --git a/linera-service/src/cli/command.rs b/linera-service/src/cli/command.rs index 479bedd48d77..01292aaf5103 100644 --- a/linera-service/src/cli/command.rs +++ b/linera-service/src/cli/command.rs @@ -1234,6 +1234,10 @@ pub enum NetCommand { #[cfg(feature = "kubernetes")] #[arg(long, default_value = "false")] dual_store: bool, + + /// Set the list of hosts that contracts and services can send HTTP requests to. + #[arg(long, value_delimiter = ',')] + http_request_allow_list: Option>, }, /// Print a bash helper script to make `linera net up` easier to use. The script is diff --git a/linera-service/src/cli/main.rs b/linera-service/src/cli/main.rs index c91c35a683f7..9ae225ca4b45 100644 --- a/linera-service/src/cli/main.rs +++ b/linera-service/src/cli/main.rs @@ -2360,6 +2360,7 @@ async fn run(options: &Options) -> Result { with_block_exporter, exporter_address: block_exporter_address, exporter_port: block_exporter_port, + http_request_allow_list, .. } => { net_up_utils::handle_net_up_service( @@ -2380,6 +2381,7 @@ async fn run(options: &Options) -> Result { *with_faucet, *faucet_port, *faucet_amount, + http_request_allow_list.clone(), ) .boxed() .await?; diff --git a/linera-service/src/cli/net_up_utils.rs b/linera-service/src/cli/net_up_utils.rs index 202994ffc9e9..00d618048c18 100644 --- a/linera-service/src/cli/net_up_utils.rs +++ b/linera-service/src/cli/net_up_utils.rs @@ -207,6 +207,7 @@ pub async fn handle_net_up_service( with_faucet: bool, faucet_port: NonZeroU16, faucet_amount: Amount, + http_request_allow_list: Option>, ) -> anyhow::Result<()> { assert!( num_initial_validators >= 1, @@ -251,7 +252,7 @@ pub async fn handle_net_up_service( num_shards, num_proxies, policy_config, - http_request_allow_list: None, + http_request_allow_list, cross_chain_config, storage_config_builder, path_provider, diff --git a/linera-service/src/cli_wrappers/local_net.rs b/linera-service/src/cli_wrappers/local_net.rs index 762441a5da50..7ecb88193a7a 100644 --- a/linera-service/src/cli_wrappers/local_net.rs +++ b/linera-service/src/cli_wrappers/local_net.rs @@ -507,7 +507,7 @@ impl LocalNet { } fn block_exporter_port(&self, validator: usize, exporter_id: usize) -> usize { - test_offset_port() + 3000 + validator * self.num_shards + exporter_id + 1 + test_offset_port() + 5000 + validator * self.num_shards + exporter_id + 1 } pub fn proxy_public_port(&self, validator: usize, proxy_id: usize) -> usize { @@ -518,8 +518,8 @@ impl LocalNet { test_offset_port() + 4000 + 1 } - fn block_exporter_metrics_port(exporter_id: usize) -> usize { - test_offset_port() + 4000 + exporter_id + 1 + fn block_exporter_metrics_port(&self, validator: usize, exporter_id: usize) -> usize { + test_offset_port() + 6000 + validator * self.num_shards + exporter_id + 1 } fn configuration_string(&self, server_number: usize) -> Result { @@ -639,7 +639,7 @@ impl LocalNet { let n = validator; let host = Network::Grpc.localhost(); let port = self.block_exporter_port(n, exporter_id as usize); - let metrics_port = Self::block_exporter_metrics_port(exporter_id as usize); + let metrics_port = self.block_exporter_metrics_port(n, exporter_id as usize); let mut config = format!( r#" id = {exporter_id} diff --git a/linera-service/src/exporter/main.rs b/linera-service/src/exporter/main.rs index 96085a56a91c..6d7809859dc3 100644 --- a/linera-service/src/exporter/main.rs +++ b/linera-service/src/exporter/main.rs @@ -1,7 +1,11 @@ // Copyright (c) Zefchain Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -use std::{path::PathBuf, time::Duration}; +use std::{ + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; use anyhow::Result; use async_trait::async_trait; @@ -157,6 +161,60 @@ struct RunOptions { pub enable_memory_profiling: bool, } +#[allow(unused_variables)] +async fn start_health_server( + address: std::net::SocketAddr, + shutdown_signal: CancellationToken, + health: Arc, + enable_memory_profiling: bool, +) { + let health_router = axum::Router::new().route( + "/health", + axum::routing::get(move || { + let is_healthy = health.load(std::sync::atomic::Ordering::Acquire); + async move { + if is_healthy { + (axum::http::StatusCode::OK, "OK") + } else { + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "unhealthy") + } + } + }), + ); + + #[cfg(with_metrics)] + { + let memory_profiling = if enable_memory_profiling { + monitoring_server::MemoryProfiling::Enabled + } else { + monitoring_server::MemoryProfiling::Disabled + }; + monitoring_server::start_metrics_with_extras( + address, + shutdown_signal, + memory_profiling, + Some(health_router), + ); + } + + #[cfg(not(with_metrics))] + { + let listener = tokio::net::TcpListener::bind(address) + .await + .expect("Failed to bind health server"); + let addr = listener.local_addr().expect("Failed to get local address"); + tracing::info!("Serving /health on {:?}", addr); + tokio::spawn(async move { + if let Err(e) = axum::serve(listener, health_router) + .with_graceful_shutdown(shutdown_signal.cancelled_owned()) + .await + { + tracing::error!("Health server error: {}", e); + } + }); + } +} + struct ExporterContext { node_options: NodeOptions, config: BlockExporterConfig, @@ -175,11 +233,22 @@ impl Runnable for ExporterContext { let shutdown_notifier = CancellationToken::new(); tokio::spawn(listen_for_shutdown_signals(shutdown_notifier.clone())); - #[cfg(with_metrics)] - monitoring_server::start_metrics_with_profiling( + let health = Arc::new(AtomicBool::new(true)); + let enable_memory_profiling = { + #[cfg(with_metrics)] + { + self.enable_memory_profiling + } + #[cfg(not(with_metrics))] + { + false + } + }; + start_health_server( self.config.metrics_address(), shutdown_notifier.clone(), - self.enable_memory_profiling, + health.clone(), + enable_memory_profiling, ) .await; @@ -190,6 +259,7 @@ impl Runnable for ExporterContext { self.node_options, self.config.id, self.config.destination_config, + health, ); let service = ExporterService::new(sender); @@ -452,3 +522,116 @@ impl Runnable for DestinationsContext { Ok(()) } } + +#[cfg(test)] +mod health_tests { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + + use linera_base::port::get_free_port; + use linera_rpc::{config::TlsConfig, NodeOptions}; + use linera_service::{ + cli_wrappers::local_net::LocalNet, + config::{Destination, DestinationConfig, LimitsConfig}, + }; + use linera_storage::{DbStorage, TestClock}; + use linera_views::memory::MemoryDatabase; + use tokio::time::{sleep, Duration}; + use tokio_util::sync::CancellationToken; + + use super::start_health_server; + use crate::{ + common::ExporterCancellationSignal, + runloops::start_block_processor_task, + test_utils::{make_simple_state_with_blobs, DummyIndexer, TestDestination}, + }; + + #[test_log::test(tokio::test)] + async fn test_health_endpoint_reflects_exporter_errors() -> anyhow::Result<()> { + let cancellation_token = CancellationToken::new(); + let health = Arc::new(AtomicBool::new(true)); + + // Start the production health server on a free port. + let health_port = get_free_port().await?; + let health_addr = std::net::SocketAddr::from(([127, 0, 0, 1], health_port)); + start_health_server( + health_addr, + cancellation_token.clone(), + health.clone(), + false, + ) + .await; + + // Start a faulty indexer destination. + let indexer_port = get_free_port().await?; + let indexer = DummyIndexer::default(); + indexer.set_faulty(); + tokio::spawn( + indexer + .clone() + .start(indexer_port, cancellation_token.clone()), + ); + LocalNet::ensure_grpc_server_has_started("faulty indexer", indexer_port as usize, "http") + .await?; + + // Prepare storage with test blocks. + let storage = DbStorage::::make_test_storage(None).await; + let (notification, _state) = make_simple_state_with_blobs(&storage).await; + + // Start the block processor with the faulty indexer and shared health flag. + let signal = ExporterCancellationSignal::new(cancellation_token.clone()); + let (notifier, _handle) = start_block_processor_task( + storage, + signal, + LimitsConfig::default(), + NodeOptions { + send_timeout: Duration::from_millis(4000), + recv_timeout: Duration::from_millis(4000), + retry_delay: Duration::from_millis(1000), + max_retries: 10, + ..Default::default() + }, + 0, + DestinationConfig { + committee_destination: false, + destinations: vec![Destination::Indexer { + port: indexer_port, + tls: TlsConfig::ClearText, + endpoint: "127.0.0.1".to_owned(), + }], + }, + health.clone(), + ); + + let base = format!("http://127.0.0.1:{health_port}"); + let client = reqwest::Client::new(); + + // Before any errors, health should be 200. + let resp = client.get(format!("{base}/health")).send().await?; + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await?, "OK"); + + // Send a block notification — the faulty indexer will cause a stream error. + notifier.send(notification)?; + + // Wait for the error to propagate and flip the health flag. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while health.load(Ordering::Acquire) { + assert!( + tokio::time::Instant::now() < deadline, + "health flag did not flip to unhealthy within timeout" + ); + sleep(Duration::from_millis(100)).await; + } + + // After the stream error, health should be 500. + let resp = client.get(format!("{base}/health")).send().await?; + assert_eq!(resp.status(), 500); + assert_eq!(resp.text().await?, "unhealthy"); + + cancellation_token.cancel(); + Ok(()) + } +} diff --git a/linera-service/src/exporter/runloops/block_processor/mod.rs b/linera-service/src/exporter/runloops/block_processor/mod.rs index b8ec848b4536..47e9393323c7 100644 --- a/linera-service/src/exporter/runloops/block_processor/mod.rs +++ b/linera-service/src/exporter/runloops/block_processor/mod.rs @@ -189,7 +189,10 @@ where #[cfg(test)] mod test { - use std::collections::HashSet; + use std::{ + collections::HashSet, + sync::{atomic::AtomicBool, Arc}, + }; use linera_base::{ crypto::CryptoHash, @@ -241,6 +244,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -378,6 +382,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -495,6 +500,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -583,6 +589,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -693,6 +700,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -783,6 +791,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( diff --git a/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs b/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs index e252dca94b3f..dbc56df69122 100644 --- a/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs +++ b/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs @@ -4,7 +4,7 @@ use std::{ future::IntoFuture, sync::{ - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }, time::Duration, @@ -29,6 +29,7 @@ pub(crate) struct Exporter { options: NodeOptions, work_queue_size: usize, destination_id: DestinationId, + health: Arc, } impl Exporter { @@ -36,11 +37,13 @@ impl Exporter { destination_id: DestinationId, work_queue_size: usize, options: NodeOptions, + health: Arc, ) -> Exporter { Self { options, destination_id, work_queue_size, + health, } } @@ -87,6 +90,7 @@ impl Exporter { res = streamer.run() => { if let Err(error) = res { + self.health.store(false, Ordering::Release); tracing::error!(?error, "exporter stream error. re-trying to establish a stream"); client = IndexerClient::new(address, self.options)?; sleep(Duration::from_millis(500)).await; @@ -94,6 +98,7 @@ impl Exporter { }, res = acknowledgement_task.run() => { + self.health.store(false, Ordering::Release); match res { Err(error) => { tracing::error!(?error, "ack stream error. re-trying to establish a stream"); diff --git a/linera-service/src/exporter/runloops/mod.rs b/linera-service/src/exporter/runloops/mod.rs index f72646c5c4ac..cc82181408fe 100644 --- a/linera-service/src/exporter/runloops/mod.rs +++ b/linera-service/src/exporter/runloops/mod.rs @@ -4,6 +4,7 @@ use std::{ collections::HashSet, future::{Future, IntoFuture}, + sync::{atomic::AtomicBool, Arc}, }; use block_processor::BlockProcessor; @@ -39,6 +40,7 @@ pub(crate) fn start_block_processor_task( options: NodeOptions, block_exporter_id: u32, destination_config: DestinationConfig, + health: Arc, ) -> ( UnboundedSender, std::thread::JoinHandle>, @@ -62,6 +64,7 @@ where block_exporter_id, new_block_queue, destination_config, + health, ) }); @@ -91,6 +94,7 @@ impl NewBlockQueue { } #[tokio::main(flavor = "current_thread")] +#[expect(clippy::too_many_arguments)] async fn start_block_processor( storage: &S, shutdown_signal: F, @@ -99,6 +103,7 @@ async fn start_block_processor( block_exporter_id: u32, new_block_queue: NewBlockQueue, destination_config: DestinationConfig, + health: Arc, ) -> Result<(), ExporterError> where S: Storage + Clone + Send + Sync + 'static, @@ -142,6 +147,7 @@ where exporter_storage.clone()?, destination_config.destinations, startup_committee_destinations, + health, ); let mut block_processor = BlockProcessor::new( @@ -224,7 +230,14 @@ where #[cfg(test)] mod test { - use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration}; + use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, + }; use linera_base::{ crypto::{AccountPublicKey, Secp256k1PublicKey}, @@ -314,6 +327,7 @@ mod test { committee_destination: false, destinations: vec![destination_address], }, + Arc::new(AtomicBool::new(true)), ); assert!( @@ -371,6 +385,7 @@ mod test { committee_destination: false, destinations: destinations.clone(), }, + Arc::new(AtomicBool::new(true)), ); assert!( @@ -427,6 +442,7 @@ mod test { destinations: destinations.clone(), committee_destination: false, }, + Arc::new(AtomicBool::new(true)), ); sleep(Duration::from_secs(4)).await; @@ -488,6 +504,7 @@ mod test { committee_destination: true, destinations: vec![], }, + Arc::new(AtomicBool::new(true)), ); let mut single_validator = BTreeMap::new(); diff --git a/linera-service/src/exporter/runloops/task_manager.rs b/linera-service/src/exporter/runloops/task_manager.rs index 0c585cec230b..1374d4a5fa0d 100644 --- a/linera-service/src/exporter/runloops/task_manager.rs +++ b/linera-service/src/exporter/runloops/task_manager.rs @@ -4,7 +4,10 @@ use std::{ collections::{HashMap, HashSet}, future::{Future, IntoFuture}, - sync::Arc, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, }; use linera_rpc::{grpc::GrpcNodeProvider, NodeOptions}; @@ -44,12 +47,14 @@ where storage: ExporterStorage, startup_destinations: Vec, current_committee_destinations: HashSet, + health: Arc, ) -> Self { let exporters_builder = ExporterBuilder::new( node_options, work_queue_size, shutdown_signal, &startup_destinations, + health, ); Self { exporters_builder, @@ -146,6 +151,7 @@ pub(super) struct ExporterBuilder { /// Full destination configs keyed by ID, needed for destinations that /// require more than just the address string (e.g. EvmChain). destination_configs: HashMap, + health: Arc, } impl ExporterBuilder @@ -158,6 +164,7 @@ where work_queue_size: usize, shutdown_signal: F, destinations: &[Destination], + health: Arc, ) -> Self { let node_provider = GrpcNodeProvider::new(options); let arced_node_provider = Arc::new(node_provider); @@ -169,6 +176,7 @@ where work_queue_size, node_provider: arced_node_provider, destination_configs, + health, } } @@ -180,14 +188,27 @@ where where S: Storage + Clone + Send + Sync + 'static, { + let shutdown_signal = self.shutdown_signal.clone(); + let health = self.health.clone(); + match id.kind() { DestinationKind::Indexer => { - let exporter_task = - super::IndexerExporter::new(id.clone(), self.work_queue_size, self.options); + let exporter_task = super::IndexerExporter::new( + id.clone(), + self.work_queue_size, + self.options, + self.health.clone(), + ); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } DestinationKind::Validator => { @@ -197,16 +218,28 @@ where self.work_queue_size, ); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } DestinationKind::Logging => { let exporter_task = LoggingExporter::new(id); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } DestinationKind::EvmChain => { @@ -215,9 +248,15 @@ where .get(&id) .expect("EvmChain destination config must exist"); let exporter_task = EvmChainExporter::new(id, destination.clone()); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } } } diff --git a/linera-service/src/exporter/tests.rs b/linera-service/src/exporter/tests.rs index 6aac15b4340e..14bce1eb7ad8 100644 --- a/linera-service/src/exporter/tests.rs +++ b/linera-service/src/exporter/tests.rs @@ -49,7 +49,7 @@ async fn test_linera_exporter(database: Database, network: Network) -> Result<() port: 0, }, limits: LimitsConfig::default(), - metrics_port: 1234, + metrics_port: 0, }; let config = LocalNetConfig { diff --git a/linera-service/src/wallet.rs b/linera-service/src/wallet.rs index 56b5f9421c87..ce96c51cace7 100644 --- a/linera-service/src/wallet.rs +++ b/linera-service/src/wallet.rs @@ -46,9 +46,6 @@ impl ChainDetails { if self.is_admin { tags.push("ADMIN"); } - if self.user_chain.is_follow_only() { - tags.push("FOLLOW-ONLY"); - } if !tags.is_empty() { println!("{:<20} {}", "Tags:", tags.join(", ")); } @@ -71,12 +68,6 @@ impl ChainDetails { println!("{:<20} {}", "Timestamp:", self.user_chain.timestamp); println!("{:<20} {}", "Blocks:", self.user_chain.next_block_height); - if let Some(epoch) = self.user_chain.epoch { - println!("{:<20} {epoch}", "Epoch:"); - } else { - println!("{:<20} -", "Epoch:"); - } - if let Some(hash) = self.user_chain.block_hash { println!("{:<20} {hash}", "Latest block hash:"); } diff --git a/linera-storage/Cargo.toml b/linera-storage/Cargo.toml index 943bae489af3..37892f1abc7c 100644 --- a/linera-storage/Cargo.toml +++ b/linera-storage/Cargo.toml @@ -21,6 +21,7 @@ wasmer = ["linera-execution/wasmer"] wasmtime = ["linera-execution/wasmtime"] scylladb = ["linera-views/scylladb"] dynamodb = ["linera-views/dynamodb"] +rocksdb = ["linera-views/rocksdb"] metrics = [ "linera-base/metrics", "linera-chain/metrics", diff --git a/scripts/check_chain_loads.sh b/scripts/check_chain_loads.sh index 1fa2b72f6da7..22a4cd9cf7a8 100755 --- a/scripts/check_chain_loads.sh +++ b/scripts/check_chain_loads.sh @@ -38,10 +38,10 @@ if [ "$(grep 'linera-service/src/cli/main.rs' "$USAGES_FILE" | wc -l)" -eq 1 ]; sed -i -e '/linera-service\/src\/cli\/main\.rs/d' "$USAGES_FILE" fi -# The linera-client uses `create_chain` to initialize the storage from the genesis configuration, -# and this is only called by the `database_tool` -if [ "$(grep 'linera-client/src/config.rs' "$USAGES_FILE" | wc -l)" -eq 1 ]; then - sed -i -e '/linera-client\/src\/config\.rs/d' "$USAGES_FILE" +# GenesisConfig uses `create_chain` to initialize storage from the genesis configuration, +# and this is only called during initial setup +if [ "$(grep 'linera-core/src/genesis_config.rs' "$USAGES_FILE" | wc -l)" -eq 1 ]; then + sed -i -e '/linera-core\/src\/genesis_config\.rs/d' "$USAGES_FILE" fi # Client::extend_with_chain uses it to add a new chain to the tracked wallet.