Skip to content

Bridge: HTLC settlement with atomic bridge-lock

Bridges are the largest single class of crypto theft. Over $2.8B has been stolen from cross-chain bridges since 2020, roughly 40% of all Web3 theft volume (CertiK / industry summary). 2026 alone logged over $750M in bridge losses in under four months (Phemex DeFi hacks 2026). The pattern in every catastrophic failure remains the same: a custody contract holds locked tokens on chain A, a federation of validators observes the lock and signs an unlock-or-mint on chain B, and a single signature compromise drains the entire pool.

2D’s bridge is built so that pattern cannot recur. There is no unlock() authority anywhere; settlement relies on preimage-locked HTLCs on both sides. There is no pre-mint trust seed; supply on the 2D side starts at zero and grows one event at a time, each one independently re-checked against finalized Ethereum state. The operator’s only role is that of a matchmaker: locking assets on one side, locking the equivalent on the other, and handing the user the preimage when they claim.

This article walks through the design choice (why HTLC over lock-mint), the bridge-lock mechanics (how supply tracks Ethereum events 1:1), the cross-chain check (what the verifier independently confirms), and the trust model that falls out.

The default architecture for a bridge is lock-mint. Alice sends USDC to a custody contract on chain A. A federation of validators observes the lock event and signs a mint(Alice, amount) call on chain B’s wrapped-token contract. The wrapped tokens are transferred; eventually, someone redeems them, the bridge executes the symmetric burn, and a corresponding unlock() call on chain A releases the original USDC.

The structural problem is that the final unlock() is unconditional from the chain’s perspective. Whoever holds the right keys (validator threshold, multisig, oracle quorum) can call unlock() for any amount up to the bridge’s TVL at any time. Phish enough keys, the entire pool walks out.

The catastrophic failures read as a who’s-who of unlock-authority compromise:

  • Wormhole (2022, ~$320M). A misused Solana helper accepted a forged guardian signature, minting wETH out of thin air.
  • Ronin (2022, ~$620M). Five of nine validator keys were phished; the attacker approved two large withdrawals.
  • Nomad (2022, ~$190M). An upgrade accidentally bypassed a check, turning the unlock path into a free-for-all.
  • Poly Network (2021, ~$611M). The cross-chain manager’s lock function could be tricked into calling unlock on arbitrary amounts.

Different surfaces, same primitive: somebody with keys could unlock.

2D replaces lock-mint with HTLC settlement on both sides. Alice locks USDC on the Ethereum HTLC contract under a hash H and a deadline. The bridge operator locks the equivalent USD-stable on the 2D HTLC under the same hash. Alice claims on 2D using the preimage P such that sha256(P) = H. The preimage then becomes public on 2D, and anyone can use it to finalize the Ethereum claim; the USDC is paid to the claimer address configured at lock time.

The unlock authority is gone. There is no unlock() callable by the operator. Funds can only move via claim(preimage) — which works only if sha256(preimage) = hash, only before the deadline, and always pays the configured claimer — or via refund(hash), which returns funds to the original sender after the deadline passes with no claim. Preimages originate in user wallets: the user picks the preimage, publishes hash = sha256(preimage), and reveals the preimage only at claim time. The verifier’s cross-chain checks (described below) prevent a compromised 2D operator key from re-routing legitimate user locks to an attacker. The claimer allowlist binding ties the cited Locked event’s claimer to a configured operator allowlist, which closes the path where an attacker could fund a self-attested Ethereum lock with attacker-chosen claimer.

Worst case for a user holding funds locked under a user-chosen preimage is refund firing and money returning where it came from — those funds cannot be moved by anyone but the user, regardless of operator state.

The HTLC swap on the 2D side requires the operator to have liquidity to lock. Where does that liquidity come from?

The default wrapped-bridge answer is “pre-mint a stockpile, trust the operator not to abscond.” 2D refuses that trust. Production day-zero has zero USD-stable in the bridge operator’s pool. The operator can only acquire USD-stable by citing a finalized Ethereum lock: every USD-stable that exists on the 2D side corresponds 1:1 to a verified Ethereum Locked event.

The mechanism lives on the BridgeRefillMint precompile at 0x2D00…0003. It exposes a single state-changing selector:

bridge_lock(uint64 eth_chain_id, bytes32 eth_tx_hash, uint32 eth_log_index,
uint256 amount, bytes32 htlc_hash, address receiver, uint256 deadline_ms,
uint64 eth_observation_block)

bridge_lock atomically mints supply and creates an HTLC lock on the 2D side in a single call, binding the Ethereum event to a specific 2D recipient. There is no standalone refill path: every minted unit is immediately locked to a specific receiver via HTLC, eliminating operator float entirely.

Calldata is the source triple identifying a single Locked event on Ethereum, plus the amount, HTLC hash, receiver, deadline, and eth_observation_block (the Ethereum "finalized" block number at signing time, stored on the bridge_mints row for replay-safe claimerUsedHash checks). The precompile does three things, in order:

  1. Reject the call unless the caller equals the configured bridge_operator_address. The operator account is restricted to precompile calls only; plain transfers from the operator address are blocked by the block executor.
  2. Insert an HTLC swap row (receiver-bound lock) and a bridge_mints row (supply backing) keyed on eth_event_id = keccak256(eth_chain_id ‖ eth_tx_hash ‖ eth_log_index). The primary key on eth_event_id guarantees a duplicate triple cannot mint twice. The htlc_hash is stored alongside the event id, linking the mint to a specific HTLC swap.
  3. If both inserts succeed, emit BridgeLocked(eth_event_id, htlc_hash, receiver, amount). Operator balance stays at zero throughout: the mint and HTLC lock are atomic.

There is no batching. One call per finalized Locked event. On free 2D transactions there is no economic pressure to amortize, and running the call per event keeps the supply invariant tight at every block.

The shape of the calldata is deliberate. An earlier design carried only the derived eth_event_id as a single bytes32. That made the id non-reversible at verifier time: the verifier needs the original (chain_id, tx_hash, log_index) triple to query Ethereum, and keccak256 does not run backward. Storing the triple alongside the derived id keeps the verifier self-contained: every fact it needs to re-prove the mint lives in the block.

The chain-side authorization on BridgeRefillMint is one check: the caller is the configured operator. That is enough to keep random users from minting, but it is nowhere near enough to guarantee that the cited event actually exists. A compromised operator key could call bridge_lock with a fabricated triple and a bogus amount; the precompile would happily insert the rows.

This is where the verifier earns its keep. After the producer executes a candidate block, but before the verifier accepts it, every new bridge_mints row gets an independent cross-chain check. Each row drives four Ethereum JSON-RPC roundtrips:

  • eth_getTransactionReceipt(tx_hash) returns the receipt; the log at log_index is inspected. htlc_hash, sender, and claimer come from indexed topics (topics[1], topics[2], topics[3]); receiverOn2D, amount, and deadline are decoded from log.data (32-byte slots in that order).
  • eth_getBlockByNumber("finalized") returns the highest finalized block number; the receipt’s block must be at or below it.
  • eth_getLogs queries for Refunded events matching the hash and sender within a bounded window after the lock.
  • eth_call to claimerUsedHash(claimer, htlc_hash) on the bridge contract queries the claimer/hash storage slot at the row’s eth_observation_block (the Ethereum finalized head frozen in bridge_lock calldata at signing time).

After confirming the Locked event exists and is finalized, the verifier performs five load-bearing bindings:

  1. HTLC hash binding. The htlc_hash stored in bridge_mints must match topics[1] of the Ethereum Locked event. Without this, an operator could cite a legitimate Locked event but supply an attacker-controlled hash, then drain supply via deadline refund.
  2. Receiver binding. The receiverOn2D address decoded from log.data[0:32] of the Ethereum event must match the receiver in htlc_swaps. This prevents routing funds to an attacker address while citing a legitimate lock.
  3. Claimer allowlist binding. The claimer decoded from topics[3] must be a member of the configured operator allowlist. Without this, an attacker holding the 2D-side operator key could fund their own Ethereum lock(...) with attacker-chosen claimer, satisfy the four self-consistency checks (the attacker chose every value), and mint unbacked 2D supply against their own recoverable USDC. The allowlist is the first protocol boundary that constrains the Ethereum-side actor to a trusted set; on Ethereum lock() itself remains permissionless. A missing or unparseable claimer is treated as not-allowlisted (fail-closed before the membership check).
  4. Refund check. The verifier queries eth_getLogs for Refunded events matching the HTLC hash and sender within a bounded block window after the lock. If the Ethereum lock was refunded before the operator submitted bridge_lock, the mint is rejected.
  5. Claimer/hash storage probe. The verifier calls claimerUsedHash(claimer, htlc_hash) at eth_observation_block persisted on the bridge_mints row (consensus state, included in the state root) when the row value is > 0. The operator embeds the current Ethereum "finalized" block when signing bridge_lock (Executor / IntentWatcher enrichment on the honest path); replay uses that frozen value, so a later on-chain claim does not change what historical verifiers read at the observation block. The verifier also requires receipt_block < eth_observation_block <= finalized_block at verify time (observation == receipt_block is rejected as vacuous). Rows with eth_observation_block == 0 are pre-migration backfill and skip this probe so historical replay can proceed. Querying receipt_block is vacuous (the slot is always zero there); querying the live finalized head for the storage probe breaks replay after a legitimate claim. Limitation: a compromised operator crafting raw bridge_lock calldata can still minimize eth_observation_block above receipt_block; consensus cannot prove the embedded value equals signing-time finality — signing policy and operational controls remain load-bearing for that adversary.

Every bridge_mints row must have an htlc_hash (fail-closed). A row without one is rejected with :missing_htlc_hash.

The verifier rejects the row if any of these conditions fails:

ReasonWhat it catches
:not_foundReceipt or log doesn’t exist on Ethereum.
:wrong_contractLog’s address isn’t the configured Ethereum HTLC contract.
:wrong_event_signatureLog’s topic[0] isn’t the canonical Locked event signature.
:chain_id_mismatchRPC’s chain id doesn’t match the row’s eth_chain_id.
:amount_mismatchLog data’s amount doesn’t equal the claimed amount.
:not_finalizedBlock exists but isn’t yet at finality.
:htlc_hash_mismatchhtlc_hash on 2D doesn’t match the Ethereum event’s topics[1].
:receiver_mismatchreceiverOn2D in the Ethereum event doesn’t match the HTLC receiver on 2D.
:ethereum_lock_refundedA Refunded event was found for this lock on Ethereum.
:claimer_not_in_allowlistThe cited Locked event’s claimer is not in the configured operator allowlist (or is missing/unparseable).
:hash_already_used_by_claimerclaimerUsedHash(claimer, hash) returned non-zero at eth_observation_block (USDC already claimed on Ethereum before the operator signed the mint).
:eth_observation_block_missingRow has invalid / non-positive eth_observation_block (not the 0 legacy sentinel).
:eth_observation_block_at_receiptObservation equals receipt_block (vacuous claimerUsedHash bypass).
:eth_observation_block_before_receiptObservation precedes the Locked event’s receipt block.
:eth_observation_block_after_finalizedObservation exceeds the Ethereum finalized head at verify time.
:missing_htlc_hashbridge_mints row has no htlc_hash (standalone refill not allowed).
:rpc_unreachable / :rpc_http_errorTransport errors; treated as verification failure.

A failure on any row aborts the block. The verifier rolls back its execution transaction (no external side effect, since the cross-chain RPC is read-only), refuses to commit, and flags the producer as a consensus violation source. Transport errors (:rpc_unreachable, :rpc_http_error) trigger retry with exponential backoff; all other errors are treated as consensus violations and raise immediately.

The ordering is load-bearing. The check runs after BlockExecutor.execute_transactions (so the new bridge_mints rows are visible inside the same SERIALIZABLE transaction) and before Chain.StateRoot.compute. Producer trust at include-time, verifier authority at finality. A compromised producer that includes an unbacked mint never reaches an honest user; every honest verifier rejects the block.

Helios — what “Ethereum RPC” actually means

Section titled “Helios — what “Ethereum RPC” actually means”

The verifier does not trust an Infura endpoint. eth_getTransactionReceipt and eth_getBlockByNumber from a remote RPC are RPC-level: the response could be anything the operator of that endpoint wants. A bridge that trusts a remote RPC for finality has, in effect, signed away its security to whoever runs that endpoint.

The recommended production deployment points the JSON-RPC URL at a local helios sidecar. Helios is a light client for Ethereum: it tracks the beacon chain’s sync committee, verifies headers cryptographically, and serves an eth_* JSON-RPC API backed by light-client-verified data. When helios is used, the trust assumption reduces to ”≥ 1/3 of the beacon sync committee is honest”, the same threshold that secures Ethereum’s finality itself.

Important caveat: helios is an operational assumption, not a protocol-level invariant. The code reads ETHEREUM_RPC_URL from the environment and makes HTTP calls to whatever endpoint is configured. Nothing prevents pointing it at Infura, Alchemy, or any other third-party RPC. If the deployment does not use helios, the bridge’s cross-chain security degrades to “trust whoever runs the configured endpoint.”

In code, the dependency is a behaviour with two implementations:

defmodule Chain.Verifier.EthereumRpc do
@callback verify_locked_event(chain_id, tx_hash, log_index, expected_amount)
:: {:ok, :verified, verified_result()} | {:error, error_reason()}
@callback check_not_refunded(htlc_hash, sender, receipt_block)
:: :ok | {:error, error_reason()}
@callback check_hash_not_used(htlc_hash, claimer, block_number)
:: :ok | {:error, error_reason()}
end

verify_locked_event returns a map containing htlc_hash_on_eth (from topics[1]), sender_on_eth (from topics[2]), claimer_on_eth (from topics[3]), receiver_on_2d (from log.data[0:32]), receipt_block, and finalized_block (current Ethereum finalized head at verification time). The CrossChainCheck uses these plus the row’s eth_observation_block to verify the HTLC hash, receiver, claimer allowlist, refund status, and claimer/hash consumption at the producer-time observation block. check_not_refunded queries for Refunded events scoped to the specific sender and hash within a bounded block window starting at receipt_block. check_hash_not_used issues an eth_call to claimerUsedHash(claimer, hash) at row.eth_observation_block. The claimer-allowlist binding is a pure-Elixir membership check against Chain.Verifier.OperatorAllowlist, no RPC roundtrip.

Chain.Verifier.EthereumRpc.HTTP makes real JSON-RPC calls against :chain, :ethereum_rpc_url, which in production points at a helios process running on the same host. Chain.Verifier.EthereumRpc.Stub returns a configurable canned response for tests. Selection is via :chain, :ethereum_rpc_module and is fail-closed: there is no compile-time default. If the application boots without ETHEREUM_RPC_URL set in production or without an explicit Stub configuration in tests, the verifier raises with a descriptive message rather than silently accepting any bridge mint.

Bridge-in (Ethereum → 2D):

  1. User locks on Ethereum. Alice calls lock(hash, claimer, receiverOn2D, amount, deadline) on the Ethereum HTLC contract (BridgeHTLC.sol) with claimer set to the bridge operator’s Ethereum address (the deployed OperatorVault), escrowing amount USDC under hash. The Locked event emits hash, sender, and claimer as indexed topics; receiverOn2D, amount, and deadline go into non-indexed log.data. The verifier cross-checks all of these.
  2. Operator waits for finality. The orchestrator watches for the Locked event, polls eth_getBlockByNumber("finalized"), and waits until the lock’s block number is at or below the finalized one. Roughly 12-15 minutes on Ethereum mainnet.
  3. Operator locks on 2D (atomic). Operator submits bridge_lock(chain_id, tx_hash, log_index, amount, hash, Alice, deadline, eth_observation_block) to 0x2D00…0003, with eth_observation_block set to the Ethereum finalized head at signing time. The precompile atomically inserts an HTLC swap (locking amount for Alice under hash) and a bridge_mints row (supply backing plus the frozen observation block). Operator balance stays at zero throughout. The verifier independently re-checks the Ethereum event, the htlc_hash, receiverOn2D, claimer allowlist, refund status, and claimerUsedHash at eth_observation_block; on success the block is committed, on failure the block is rejected.
  4. Alice claims on 2D. Alice’s wallet calls claim(preimage) on the 2D HTLC at 0x2D00…0001. Because sha256(preimage) = hash and the deadline has not passed, the HTLC credits amount USD-stable to Alice.
  5. Operator claims on Ethereum (automated). The preimage is now visible on the 2D chain in the claim transaction’s calldata and HTLC_Claimed log. The operator’s IntentWatcher automatically submits claim(sender, hash, preimage) directly to the canonical BridgeHTLC contract (signer policy enforces to = bridge_contract_address). If the claim transaction gets stuck in the mempool, the watcher submits gas-bumped replacement transactions with the same nonce and a configured client-compatible price bump, up to a configurable limit. If all attempts fail, the intent reaches a terminal state and the operator is alerted. The payout cannot be redirected: USDC is transferred to the lock’s configured claimer (the operator vault for production bridge-ins). The claim path is also permissionless: any external watcher can submit the same preimage independently. Exceptional recovery (proxy-routed claims, RPC down) may use mix bridge.record_eth_claim as documented in the operator runbook.

Bridge-out (2D → Ethereum) uses a first-LP atomic swap. The user generates the preimage, requests a quote from POST /api/v1/bridge/out/quote, then locks 2D USD-stable in the 2D HTLC at 0x2D00…0001 with the LP as receiver. The LP watcher verifies the exact lock fields against the quote and locks Ethereum USDC from a dedicated LP wallet in BridgeHTLC.lock(hash, claimer=userEth, receiverOn2D=lp2D, amount, deadline). The user claims USDC on Ethereum, revealing the preimage; the LP uses the same preimage to claim the 2D HTLC. If either side stalls, the original sender refunds after that side’s deadline. The 2D deadline is configured longer than the Ethereum deadline so the LP has time to recover after the user claims Ethereum.

The bridge-in OperatorVault does not fund user-facing bridge-out and does not gain a vault-as-lock() sender path. OperatorVault.bridgeOut(...) remains reserved for governance-authorized treasury / rebalance operations. The pilot is fail-closed: new quotes stop when the service is disabled, LP liquidity or caps are exhausted, or monitoring is unhealthy; active swaps are still driven to claim or refund. Initial defaults are conservative and configurable: 100 USDC per swap, 1000 USDC rolling 24h quote volume, and min(50 bps, 10 USDC) LP fee.

bridge_lock gains an 8th ABI argument (eth_observation_block); compute_bridge_mints_root includes the new column. Treat this as a hard fork for any deployment with historical bridge_mints rows:

  • Recommended (pilot / devnet): reset chain DB (mix ecto.reset or fresh genesis) before running migration 20260604130100.
  • In-place migration: existing rows get eth_observation_block = 0 and skip Layer 6b on replay; new mints must persist > 0 via the precompile.
  • Ethereum RPC: verifiers need archive-capable state for eth_call to claimerUsedHash at historical observation blocks.

The bridge operator is a single party operationally but uses two cryptographically distinct keys: a 2D-side key that signs bridge_lock precompile calls (the block executor blocks every other transaction type from this address), and an Ethereum-side signing key that signs only bridgeOut(...) on the deployed OperatorVault smart contract. Ethereum-side claim(sender, hash, preimage) is permissionless: any watcher can submit the revealed preimage, and the vault wrapper verifies that the lock’s configured claimer is the vault before forwarding into BridgeHTLC. Vault governance (allowlist, caps, signing-key rotation, upgrades) is owned by a separate governance principal; the operational signing key has no governance authority. The two operational keys are held by separate signers, and each signer additionally runs a vendor-side allowlist policy as defense-in-depth on top of the on-chain constraints: the 2D-side signer refuses anything except the bridge_lock(...) selector to the bridge precompile; the Ethereum-side signer refuses anything except bridgeOut(address,uint256) to the configured OperatorVault contract. Direct ERC-20 transfers from the pool, lock(), refund(), and calls to any other contract are rejected on-chain by the vault and at the policy layer before the HSM is asked to sign. Compromise of each key is treated as an independent event below.

ThreatOutcome
2D-side operator key compromiseA 2D-side-only key compromise cannot mint unbacked supply: the verifier’s claimer-allowlist binding rejects any cited Locked event whose claimer is not in the configured operator allowlist. Since BridgeHTLC.lock() is permissionless on Ethereum but the verifier constrains the cited claimer, an attacker self-funding an Ethereum lock with attacker-chosen claimer is rejected at the claimer-allowlist binding before any 2D-side mint commits. The block executor also blocks plain transfers from the 2D operator address; user-locked USDC under user-chosen preimages is untouched; the operator’s accumulated USDC pool is untouched. The remaining residual risk requires a lock that cites an allowlisted claimer such as the deployed vault; see the “joint compromise” row below for that case.
Ethereum-side signing-key compromiseThe Ethereum-side signing key is the operational key for bridgeOut(destination, amount) on the deployed OperatorVault. The vault holds the USDC pool accumulated from completed bridge-ins. The signing key cannot transfer USDC arbitrarily: the only privileged outflow path is bridgeOut(...), bounded on-chain by bridgeOutAllowlist, perTxCap, and a rolling 24h cumulativeCap. Ethereum-side claim(...) is not a signing-key privilege; anyone can submit a valid preimage, and the payout goes to the lock’s configured claimer. A signing-key-only compromise drains at most cumulativeCap per 24h, only to allowlisted destinations, with the rest of the pool stuck under governance-only control. User-locked USDC in pending bridge-ins is unaffected: those locks have user-chosen preimages that the attacker does not have. The 2D supply is unaffected: minting requires the 2D-side key. Defense-in-depth: the signing-service policy in front of the signing key additionally refuses anything except bridgeOut(...) to the vault.
Joint compromise of 2D-side and Ethereum-side signing keysBridgeHTLC.lock() is permissionless on Ethereum, so an attacker holding the 2D-side operational key can self-fund a lock from any EOA with claimer = vault (the allowlisted vault address) and receiverOn2D = attacker. The claimer-allowlist binding passes trivially, and the 2D-side key mints N 2D-supply for the attacker. Layer 6b (claimerUsedHash at signing-time eth_observation_block) catches USDC already claimed on Ethereum before mint when the operator embeds current finality on the honest path; it does not fully close a malicious operator minimizing eth_observation_block in raw calldata. The attacker claims on 2D, revealing the preimage. At that point any watcher can call BridgeHTLC.claim(...) or OperatorVault.claim(...) before the Ethereum deadline, and the contract always pays the configured claimer, so the original N USDC lands in the vault. The attacker would have to prevent every watcher from submitting the public preimage until the deadline to recover the Ethereum lock by refund. The Ethereum-side signing key alone cannot drain the vault beyond cumulativeCap per 24h to allowlisted destinations. Full pool drain requires also compromising governance — see next row.
LP wallet or service compromiseThe LP is not the bridge operator and does not control bridge-in minting. A compromised LP service can stop quoting, quote badly, fail to lock Ethereum, or lose the dedicated LP capital it controls. It cannot move OperatorVault funds, mint 2D supply, or spend user funds already locked in HTLCs without the corresponding preimage. Pilot caps and fail-closed quoting bound the exposed LP inventory while the market-making path matures.
Vault governance compromiseThe vault owner (intended to be a multisig + timelock) controls setSigningKey, setBridgeOutAllowlist, setPerTxCap, setCumulativeCap, and authorizes UUPS upgrades. A governance compromise can rotate the signing key, add an attacker-controlled allowlist destination, raise caps without bound, or upgrade the contract to bypass all policy — any of which lets the attacker drain the entire pool. Operational mitigation: hold governance behind a multisig with hardware-key signers and a timelock window long enough for honest operators to detect and respond to a malicious policy mutation before it activates.
Operator submits fabricated bridge_lockThe precompile only checks caller == operator and inserts the rows. The cross-chain check runs later, on the verifier side, during block replay. An honest verifier rejects the block if the cited Ethereum event is not found, has a wrong contract/signature/hash, or is not yet finalized. Protection is at verifier replay, not at block production time.
Compromised producer includes an unbacked bridge_lockSame path. Every honest verifier independently replays the block and rejects it. The producer’s block never reaches users who connect through honest verifiers.
User fails to claim before deadlinePer-swap loss bound. refund(hash) returns funds to the original sender after the deadline.
Helios sidecar compromiseIf helios is deployed: equivalent to ≥ 2/3 of the beacon sync committee being malicious. If helios is not deployed and ETHEREUM_RPC_URL points at a third-party endpoint, cross-chain security degrades to trusting that endpoint’s operator. Helios is an operational best practice, not a protocol-enforced invariant.
Duplicate event submitted twiceRejected at the chain side. bridge_mints PK on eth_event_id is committed in the state root, so a producer that bypassed the PK check would still be caught by the verifier’s state-root recomputation.

The bridge eliminates the unlock() authority that has drained wrapped bridges. Every mint is atomically bound to a receiver-specific HTLC via bridge_lock, so the operator never holds unbound balance. The verifier independently confirms the HTLC hash, receiver, claimer allowlist, refund status, and claimerUsedHash(claimer, htlc_hash) at the eth_observation_block frozen in bridge_mints (embedded in bridge_lock calldata at signing time) before accepting any block containing a mint. Funds locked in HTLC swaps are safe: only claim(preimage) or refund(hash) can move them, and a successful claim always pays the configured claimer.

Production deploys OperatorVault.sol as the Ethereum-side operator wallet. The vault is a UUPS-upgradeable smart contract that splits the operator into two roles enforced on-chain.

Permissionless claim finalization. claim(sender, hash, preimage) can be called by any watcher. The vault first checks that BridgeHTLC.lockClaimer(sender, hash) == address(this), then forwards into BridgeHTLC.claim(sender, hash, preimage). Recovered USDC accumulates in the vault; the caller never chooses the payout address.

Signing key (operational). The signing key has no governance authority and only authorizes bridgeOut(destination, amount), which transfers vault USDC out to allowlisted destinations under caps. This path is used only for governance-authorized outbound operations such as treasury rebalances to a multisig. User-facing bridge-out uses the LP flow described above and does not spend vault funds. The vault call is bounded by three on-chain caps: destination must be in bridgeOutAllowlist; amount must be at most perTxCap; and the exact rolling 24h cumulative outflow tracked by timestamped checkpoints must stay at or below cumulativeCap after this call.

The signing key cannot call lock(), cannot transfer USDC directly, cannot call any other contract. A signing-key-only compromise drains at most cumulativeCap per 24h, only to allowlisted destinations.

Governance (policy and upgrades). The contract owner — intended to be a multisig with a timelock — owns every policy lever (setSigningKey, setBridgeOutAllowlist, setPerTxCap, setCumulativeCap) and authorizes UUPS upgrades. The signing key cannot rotate itself, change caps, edit the allowlist, or upgrade the contract.

Switching the bridge over to vault-based claims is gated on the 2D verifier’s claimer allowlist. Binding 3 of the cross-chain check is what makes the vault load-bearing: if the verifier still allowlists only the legacy operator EOA, an Ethereum lock with claimer = vault is rejected at binding 3, the producer’s block is dropped by every honest verifier, and the locked USDC is recoverable on Ethereum via refund(hash) after the deadline. The deployment order is therefore:

  1. Deploy OperatorVault. Configure governance, signing key, perTxCap, cumulativeCap, and bridgeOutAllowlist.
  2. Add the vault address to the verifier’s OperatorAllowlist and roll the configuration to every honest verifier.
  3. Only after step 2 has propagated may dApps, the orchestrator, or any intent service start emitting Ethereum locks with claimer = vault.

Inverting this order does not lose funds, but every vault-claimer lock created before the verifier rolls the allowlist will fail bridge-in and have to wait for a refund.

The verifier OperatorAllowlist is treated as append-only during the migration. Adding the vault expands {legacy_EOA} to {legacy_EOA, vault}. The legacy EOA is not removed as a normal migration step:

  • Replay determinism. Old Locked events with claimer = legacy_EOA must continue to verify when a fresh node replays the historical chain. Removing the legacy address would break replay of any block that cited a legacy-claimer event.
  • Cutover overlap. While dApps roll over, both claimer = legacy_EOA and claimer = vault locks coexist on Ethereum. Both stay verifiable on 2D during this window.

Operationally the orchestrator stops citing legacy-EOA locks once the vault is live (it only calls bridge_lock for vault-claimer events going forward), but the legacy address stays in the allowlist permanently. Removing an address is a separate procedure that requires confirming no historical block references it on any honest chain history — generally only safe once any chain segment that cited the address has aged out of practical replay.

Where the bridge sits in the rest of the chain

Section titled “Where the bridge sits in the rest of the chain”

The bridge composes three pieces documented separately. The verifier’s block-by-block recheck extends to bridge_mints via the cross-chain hook described above. The state-root layout commits the bridge_mints dedup invariant so a malicious producer cannot double-mint without breaking the chain hash. The HTLC primitive that does the actual settlement runs as a precompile; the bridge is a particular protocol layered on top of that primitive, not a contract deployed into a virtual machine.