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.
HTLC settlement conditions
Section titled “HTLC settlement conditions”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.
Bridge-lock and the supply invariant
Section titled “Bridge-lock and the supply invariant”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:
- 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. - Insert an HTLC swap row (receiver-bound lock) and a
bridge_mintsrow (supply backing) keyed oneth_event_id = keccak256(eth_chain_id ‖ eth_tx_hash ‖ eth_log_index). The primary key oneth_event_idguarantees a duplicate triple cannot mint twice. Thehtlc_hashis stored alongside the event id, linking the mint to a specific HTLC swap. - 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.
What the verifier checks
Section titled “What the verifier checks”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 atlog_indexis inspected.htlc_hash,sender, andclaimercome from indexed topics (topics[1],topics[2],topics[3]);receiverOn2D,amount, anddeadlineare decoded fromlog.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_getLogsqueries forRefundedevents matching the hash and sender within a bounded window after the lock.eth_calltoclaimerUsedHash(claimer, htlc_hash)on the bridge contract queries the claimer/hash storage slot at the row’seth_observation_block(the Ethereum finalized head frozen inbridge_lockcalldata at signing time).
After confirming the Locked event exists and is finalized, the verifier performs five load-bearing bindings:
- HTLC hash binding. The
htlc_hashstored inbridge_mintsmust matchtopics[1]of the EthereumLockedevent. This binds both locks to the same secret. - Receiver binding. The
receiverOn2Daddress decoded fromlog.data[0:32]of the Ethereum event must match the receiver inhtlc_swaps. This prevents routing funds to an attacker address while citing a legitimate lock. - Claimer allowlist binding. The
claimerdecoded fromtopics[3]must be a member of the configured operator allowlist. Without this, an attacker holding the 2D-side operator key could fund their own Ethereumlock(...)with attacker-chosenclaimer, 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 Ethereumlock()itself remains permissionless. A missing or unparseable claimer is treated as not-allowlisted (fail-closed before the membership check). - Refund check. The verifier queries
eth_getLogsforRefundedevents matching the HTLC hash and sender within a bounded block window after the lock. If the Ethereum lock was refunded before the operator submittedbridge_lock, the mint is rejected. - Claimer/hash storage probe. The verifier calls
claimerUsedHash(claimer, htlc_hash)ateth_observation_blockpersisted on thebridge_mintsrow (consensus state, included in the state root) when the row value is> 0. The operator embeds the current Ethereum"finalized"block when signingbridge_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 requiresreceipt_block < eth_observation_block <= finalized_blockat verify time (observation == receipt_blockis rejected as vacuous). Rows witheth_observation_block == 0are pre-migration backfill and skip this probe so historical replay can proceed. Queryingreceipt_blockis 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 rawbridge_lockcalldata can still minimizeeth_observation_blockabovereceipt_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:
| Reason | What it catches |
|---|---|
:not_found | Receipt or log doesn’t exist on Ethereum. |
:wrong_contract | Log’s address isn’t the configured Ethereum HTLC contract. |
:wrong_event_signature | Log’s topic[0] isn’t the canonical Locked event signature. |
:chain_id_mismatch | RPC’s chain id doesn’t match the row’s eth_chain_id. |
:amount_mismatch | Log data’s amount doesn’t equal the claimed amount. |
:not_finalized | Block exists but isn’t yet at finality. |
:htlc_hash_mismatch | htlc_hash on 2D doesn’t match the Ethereum event’s topics[1]. |
:receiver_mismatch | receiverOn2D in the Ethereum event doesn’t match the HTLC receiver on 2D. |
:ethereum_lock_refunded | A Refunded event was found for this lock on Ethereum. |
:claimer_not_in_allowlist | The cited Locked event’s claimer is not in the configured operator allowlist (or is missing/unparseable). |
:hash_already_used_by_claimer | claimerUsedHash(claimer, hash) returned non-zero at eth_observation_block (USDC already claimed on Ethereum before the operator signed the mint). |
:eth_observation_block_missing | Row has invalid / non-positive eth_observation_block (not the 0 legacy sentinel). |
:eth_observation_block_at_receipt | Observation equals receipt_block (vacuous claimerUsedHash bypass). |
:eth_observation_block_before_receipt | Observation precedes the Locked event’s receipt block. |
:eth_observation_block_after_finalized | Observation exceeds the Ethereum finalized head at verify time. |
:missing_htlc_hash | bridge_mints row has no htlc_hash (standalone refill not allowed). |
:rpc_unreachable / :rpc_http_error | Transport 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()}endverify_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 / bridge-out walkthrough
Section titled “Bridge-in / bridge-out walkthrough”Bridge-in (Ethereum → 2D):
- User locks on Ethereum. Alice calls
lock(hash, claimer, receiverOn2D, amount, deadline)on the Ethereum HTLC contract (BridgeHTLC.sol) withclaimerset to the bridge operator’s Ethereum address (the deployedOperatorVault), escrowingamountUSDC underhash. TheLockedevent emitshash,sender, andclaimeras indexed topics;receiverOn2D,amount, anddeadlinego into non-indexedlog.data. The verifier cross-checks all of these. - Operator waits for finality. The orchestrator watches for the
Lockedevent, pollseth_getBlockByNumber("finalized"), and waits until the lock’s block number is at or below the finalized one. Roughly 12-15 minutes on Ethereum mainnet. - Operator locks on 2D (atomic). Operator submits
bridge_lock(chain_id, tx_hash, log_index, amount, hash, Alice, deadline, eth_observation_block)to0x2D00…0003, witheth_observation_blockset to the Ethereum finalized head at signing time. The precompile atomically inserts an HTLC swap (lockingamountfor Alice underhash) and abridge_mintsrow (supply backing plus the frozen observation block). Operator balance stays at zero throughout. The verifier independently re-checks the Ethereum event, thehtlc_hash,receiverOn2D, claimer allowlist, refund status, andclaimerUsedHashateth_observation_block; on success the block is committed, on failure the block is rejected. - 2D HTLC claim (permissionless). Anyone who holds the preimage calls
claim(preimage)on the 2D HTLC at0x2D00…0001. Credit always goes to the lock’sreceiver(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 2DHTLC_Claimedlog. - Ethereum claim (permissionless). Anyone can submit
claim(sender, hash, preimage)toBridgeHTLC; USDC pays the lock’s configuredclaimer(the operator vault for production bridge-ins). On chain 77,IntentWatcherdoes 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 frometh_getTransactionByHashnonce/gasPrice, capped at SignerPolicy hard max. Exceptional recovery (RPC down) may usemix bridge.record_eth_claimas 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.
Upgrading existing chains (hard fork)
Section titled “Upgrading existing chains (hard fork)”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.resetor fresh genesis) before running migration20260604130100. - In-place migration: existing rows get
eth_observation_block = 0and skip Layer 6b on replay; new mints must persist> 0via the precompile. - Ethereum RPC: verifiers need archive-capable state for
eth_calltoclaimerUsedHashat historical observation blocks.
Trust model summary
Section titled “Trust model summary”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.
| Threat | Protection boundary and residual risk |
|---|---|
| POA operator key compromise | An 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 compromise | bridgeOut 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 compromised | An 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 compromise | May 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 compromise | Can 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 block | The 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 expiry | Ordinary 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 compromise | A 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 event | Unique 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.
Bridge operator policy
Section titled “Bridge operator policy”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.
Release gate
Section titled “Release gate”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:
- Deploy
OperatorVault. Configure governance, signing key,perTxCap,cumulativeCap, andbridgeOutAllowlist. - Add the vault address to the verifier’s
OperatorAllowlistand roll the configuration to every honest verifier. - 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.
Append-only allowlist
Section titled “Append-only allowlist”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
Lockedevents withclaimer = legacy_EOAmust 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_EOAandclaimer = vaultlocks 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.