security(operator): Terra watcher must wait for confirmation depth #182
Labels
No labels
agent:implement
agent:ready
backend
bug
cannot-reproduce
confirmed
desktop
docs
documentation
duplicate
enhancement
feature
frontend
good first issue
help wanted
high-risk
in-review
invalid
mobile
needs-triage
P0-critical
P1-high
P2-medium
P3-low
qa
QA
question
ready
report
responsive
security
security-escalate
smart-contract
solana
tablet
test-pass
ux
wallet-issue
wallet:keplr
wallet:metamask
wallet:station
wallet:walletconnect
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-bridge-monorepo#182
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The operator Terra Classic deposit watcher ingests through LCD latest height and immediately persists
last_terra_blockplusterra_depositsrows. There is no confirmation-depth subtraction. The EVM watcher already does this:get_finalized_blockreturns quorumeth_blockNumberminusfinality_blocks. After a Terra reorg, orphaned rows staypendingand are never revalidated against the canonical chain.This is not #115 (operator EVM RPC hardening /
finality_blocksdefault of 1). This is not #114 (canceler EVM poll-to-latest). Those tickets covered EVM confirmation depth, not this Terra ingest path. This is not #180 (wasmsrc_decimals) or #175 (min_signatures).Internal review id: RS-H1 (high). Still in source as of 2026-09-12 (
packages/operator/src/watchers/terra.rsonmain).Bundle (same ticket, do not split):
TerraConfig(env + deserialize default). Poll end must belatest.saturating_sub(finality_blocks), matching the EVM watcher.WithdrawApprove, re-check the deposit against canonical LCD state at a finalized height (tx still in the block, hash still in bridge storage). Do not approve from a tip-height DB row alone.terra_depositsrow is absent from the canonical height window, mark it invalid / drop it from pending. Do not leave reorged rows pending forever.Founder-required operator ingest / dest approve. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Funds at risk today on any live operator that dest-approves Terra-origin withdrawals from LCD-latest state. Terra Classic LCD
/blocks/latestis not a finality gadget. A deposit (lock / CW20 lock / mintable burn) that appears in a tip block can be stored and used as the source of truth for destWithdrawApproveon EVM or Solana. If that block is later orphaned, dest may already be approved (cancel window running or elapsed) while the Terra lock never landed on the canonical chain.This is not permissionless theft of unrelated vault inventory: the attacker (or a colliding honest user after a halt/reorg) needs a Terra deposit that the operator observed at the tip. Severity is high because dest mint/unlock is the payout, and the operator is the watchtower that is supposed to wait for source finality. Rate limits and dest cancel windows are not a substitute for source confirmation depth.
Hypothetical-only if every deployed operator already waits off-tree (they do not:
TerraConfighas nofinality_blocksfield) or if dest approve always failed closed on missing LCD storage (V2verify_terra_depositqueries latest contract state, which still includes a tip-height deposit). Sticky until the watcher cursor and dest-approve re-check land.Do not publish a mainnet tip-deposit then dest-approve sequence.
Current codebase
Terra watcher processes LCD latest with no subtraction
TerraWatcher::runreadsget_last_terra_block, thenget_current_height(), then:On success it calls
update_last_terra_block(..., height). There is nosaturating_sub.TerraWatcherholdslcd_url,bridge_address,chain_id,db,httponly — no finality field.get_current_heightuses LCD/cosmos/base/tendermint/v1beta1/blocks/latestand parsesblock.header.height. That is the same class of “head” the EVM watcher deliberately does not poll through.Insert is once; cursor never rewinds
process_blockqueries txs at that height, parses V2deposit_native/deposit_cw20_lock/deposit_cw20_mintable_burn, andinsert_terra_depositifterra_deposit_exists(tx_hash, nonce)is false. No later pass re-fetches that height. After a reorg:last_terra_blockhas already advanced past the orphaned height, so the loop will not re-scan it.terra_deposits(pendinguntil dest sync marksprocessed).EVM watcher already subtracts
finality_blocksEvmWatcher::get_finalized_block(poll uses this ascurrent_block):EvmConfig.finality_blocksis envFINALITY_BLOCKSwith per-chain defaults (default_finality_for_native_chain: BSC 15, opBNB 12).TerraConfighasrpc_url,lcd_url,chain_id,bridge_address,mnemonic,fee_recipient,this_chain_id— no confirmation depth.Closed #115 EVM-02 called out a weak default of 1 on that EVM field; it treated the mechanic as already present. Terra never got the mechanic.
Dest approve re-queries LCD latest, not a finalized height
V2 dest approve (
EvmWriter::enumerate_and_approve→verify_deposit_on_source→verify_terra_deposit) smart-queries Terraxchain_hash_idon current LCD. A deposit that exists only in the tip block still returns data. There is no check thatdeposit.block_height + N <= lcd_latest.A leftover
process_depositpath still builds dest approval from aTerraDepositDB row (hash from stored sender/amount/nonce) without a finalized-height re-fetch.process_pendingtoday prefers on-chain enumeration; do not re-wire that helper without the same N-deep canonical check.Solana dest has
CommitmentConfig(finalizeddefault) for Solana RPC. That does not protect Terra-source ingest.Invariants
terra_finality_blocks. Depth0is test-only; production default must be ≥ 1 and documented.WithdrawApprovefor a Terra-source hash requires the deposit still present in bridge storage and its source height at least N behind LCD latest (or equivalently: included in a block atfinalized_height). Tip-height inclusion is not enough.terra_depositsrow’s tx/hash is missing at the finalized height, it must leavepending(invalidated). Status/pending_depositsmust not keep advertising a reorged lock.withdrawSubmit, hash inputs, or EVMfinality_blocks. Do not skip dest verify and approve solely because a DB row exists.Constraints / guardrails
TerraConfig.finality_blocks(name can match EVM) andTERRA_FINALITY_BLOCKS(do not overloadFINALITY_BLOCKSused by EVM).last_terra_blockglobally on every LCD blip; invalidate rows whose txs vanished, or re-scan a short lookback behind finalized height if a height drop is detected. Pick one; document it; test a height drop.FINALITY_BLOCKShere (#115).ready. No public mainnet reorg recipe.Relevant files
packages/operator/src/watchers/terra.rs..=current_height; insert + cursor; no finality; tests are parse-onlypackages/operator/src/watchers/evm.rsget_finalized_blockpackages/operator/src/config.rsEvmConfig.finality_blocksvsTerraConfigwith nonepackages/operator/src/writers/evm.rsverify_terra_deposit(LCD latest); leftoverprocess_depositpackages/operator/src/db/mod.rsget_last_terra_block/update_last_terra_block/get_pending_terra_depositsRecommended direction
finality_blocks: u64toTerraConfig(default documented and ≥ 1 for non-test).TerraWatcherstores it. Poll end =current_height.saturating_sub(finality_blocks). Ifcurrent_height <= last_heightor finalized end ≤ last, sleep (same as today).xchain_hash_idhit, require source block height ≤ finalized height (from the same LCD latest minus N, or from the deposit’sblock_heightvs N). If only the tx hash is known, re-query txs at that height (existinglcd_get_txs_event_url_contract_at_height) and confirm the tx is still included.terra_depositswhose height is now behind finalized head but whose tx is missing → statusinvalid/reorged(name as in repo conventions). Do not dest-approve them.Acceptance criteria
terra_finality_blocks = N(N ≥ 1), the watcher does notprocess_blockforheight > lcd_latest - Nand does not advancelast_terra_blockinto that window.WithdrawApproveis not submitted for a Terra-source hash whose deposit height is still within N of LCD latest.pending.FINALITY_BLOCKS/get_finalized_blockbehavior unchanged. Terra env is a separate knob.Test plan (functional paths)
verify_terra_deposit/ approve gate false (AC2)pendingfinality_blocksTest plan (attack, hack, and abuse)
Non-exploitative. Local LCD mocks / operator unit tests only. Do not use these as a mainnet recipe.
withdrawSubmitimmediatelydataalready false; keep it)Verification criteria
watchers/terra.rsforsaturating_sub(or equivalent) on the poll end; grepTerraConfigforfinality_blocks.datais non-null”.Out of scope
/health,eth_chainId, and raising EVM default confirmation depth.finalizedby default) except where a Terra-source dest-approve on Solana must use the same N-deep Terra re-check.First-pass model recommendation
Recommendation: grok-high
Rationale: Security class and founder-required operator dest-approve / source ingest (wallet / bridge funds). Composer is disallowed (High/security; not a low-risk first pass). Scope is not a local three-file tweak:
watchers/terra.rspoll+cursor,TerraConfig+ env, dest-approve re-check inwriters/evm.rs(and any Solana dest Terra-source verify), DB pending invalidation, and new tests. A wrong allow (ingest or dest-approve at LCD latest) can mint dest assets for a lock that never finalized. Verify with mocked LCD height/tx fixtures, not a live chain reorg.