Skip to content

Bridge: HTLC settlement with atomic bridge-lock

Production status (chain 77): POA-to-Ethereum withdrawals are disabled. The LP bridge-out flow below describes a feature-gated implementation, not an available production withdrawal route. Do not rely on POST /api/v1/bridge/out/quote or lock funds for this flow until a separately reviewed activation is announced. Ethereum-to-POA deposits are a separate path.

The bridge links an Ethereum USDC lock to a receiver-bound HTLC on POA. The verifier checks the source event and its bindings before accepting the block. HTLCs constrain settlement; safety also depends on the implementation, availability of both chains, signers, and OperatorVault governance.

A lock specifies a secret hash, a recipient, and a deadline. Before the deadline, anyone knowing the secret may submit claim; the configured recipient receives the funds. An unclaimed lock may be refunded after its deadline under the rules of the corresponding chain.

For a user-created HTLC, the user chooses the secret. In the current quoted inflow, the operator holds the secret until settlement; the quote does not return it to the application. These flows have different trust boundaries. The entire deposit flow must not be described as exclusively user-controlled secret disclosure.

After an Ethereum claim, deposited USDC accumulates in OperatorVault. Its operational key is restricted by recipient and amount limits, while governance can change policy and upgrade the contract. Absence of a method named unlock does not eliminate governance compromise or shared-code risk.

Each bridge deposit is linked to a finalized Ethereum Locked event. The operator receives no freely spendable POA balance: the bridge record is immediately bound to a receiver-specific HTLC.

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 creates a bridge_mints backing record and a receiver-bound HTLC. The receiver is credited on successful claim. On timeout, the POA lock closes without crediting the operator; original USDC is refunded on Ethereum under its HTLC rules.

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 backing record and HTLC creation 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 precompile checks caller authority but does not itself query Ethereum to prove that the cited event exists. An operator signature is therefore insufficient evidence of backing; the verifier checks the source event and its bindings separately.

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. This binds both locks to the same secret.
  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 configuration points ETHEREUM_RPC_URL at a local Helios light client. Its verification depends on a trusted checkpoint and light-client rules. Checkpoint correctness and freshness, client version, and local process security remain required assumptions. Compromising the process or its RPC is not equivalent to compromising Ethereum consensus.

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 operators must point at a verified data source. A local Helios deployment must be checked for the specific environment. 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. 2D HTLC claim (permissionless). Anyone who holds the preimage calls claim(preimage) on the 2D HTLC at 0x2D00…0001. Credit always goes to the lock’s receiver (Alice), not the caller. For quoted inflow the operator custodians the preimage until this claim — the dApp quote does not return it. After the claim, the preimage is in the 2D HTLC_Claimed log.
  5. Ethereum claim (permissionless). Anyone can submit claim(sender, hash, preimage) to BridgeHTLC; USDC pays the lock’s configured claimer (the operator vault for production bridge-ins). On chain 77, IntentWatcher does not auto-sign this via NetHSM (BRIDGE_NETHSM_SIGNING_ENABLED=false). After the 2D mint receipt it submits the claim from the operator gas EOA (BRIDGE_ETH_HTLC_CLAIM_KEY_FILE). Unset the key file (or disable the watcher) to stop auto-claims. Stuck gas-EOA txs are replaced from eth_getTransactionByHash nonce/gasPrice, capped at SignerPolicy hard max. Exceptional recovery (RPC down) may use mix bridge.record_eth_claim as documented in the operator runbook.

Disabled in production: the feature-gated bridge-out (POA → Ethereum) implementation 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.

Resetting the database is not a normal upgrade step for production chain 77. ABI or state-root changes require a verified migration plan, backups, coordinated producer/verifier activation, and historical replay checks. The following notes describe compatibility for a historical migration.

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:

  • Disposable development environments only: 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 POA operator key signs bridge_lock; the Ethereum operational key signs OperatorVault.bridgeOut. Vault governance is a separate role that changes recipients, caps, signers, and contract implementation. Ethereum claim does not require the operational key: any observer may submit the disclosed secret, but cannot change the recipient.

Signer policy additionally restricts contracts and methods. This is a separate defense that must be verified alongside contract rules and verifier behavior.

ThreatProtection boundary and residual risk
POA operator key compromiseAn honest verifier checks the Ethereum event, recipient, allowlisted claimer, and other bindings. An operator signature alone cannot authorize arbitrary supply. A self-funded lock naming the allowlisted vault needs separate analysis below.
Ethereum operational key compromisebridgeOut remains subject to bridgeOutAllowlist, perTxCap, and rolling 24-hour cumulativeCap. The key cannot change those rules. It does not itself reveal pending HTLC secrets; custody of quoted-inflow secrets is a separate concern.
Both operational keys compromisedAn attacker may fund an Ethereum lock with claimer = vault and an attacker-controlled receiverOn2D. claimerUsedHash detects prior consumption at the supplied observation block, but does not prove that a maliciously chosen eth_observation_block equals signing-time finalized height. After POA claim, the secret is public and any observer can submit Ethereum claim to the vault before expiry. Vault caps remain until governance is compromised.
LP compromiseMay stop quotes, disrupt swap execution, or lose LP capital. It does not confer vault control or bridge-in mint authority. Public LP withdrawals are currently disabled.
Vault governance compromiseCan rotate the signer, allow new recipients, lift limits, or upgrade the contract. The entire pool is at risk. Multisig, hardware keys, and timelock controls require separate verification.
Fabricated bridge_lock or unbacked blockThe verifier performs cross-chain checks after transaction execution and before block acceptance. Protection depends on an honest verifier and trustworthy Ethereum RPC; the precompile’s caller check alone is insufficient.
Deadline expiryOrdinary POA lock refunds previously debited funds to its sender. A bridge_lock refund closes the record without a POA credit; original USDC is refunded on Ethereum under its HTLC rules.
Helios or RPC compromiseA compromised local process may supply false data independently of Ethereum consensus. Verify the image, checkpoint, and network boundary; third-party RPC adds trust in its operator.
Duplicate source eventUnique eth_event_id and the inclusion of bridge_mints in the state root bind replay checks to block re-execution.

HTLC does not bound every failure to one swap: shared code, verification, or governance failures can affect multiple locks and the entire backing pool.

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.