security(operator): scope EVM deposit nonce lookup by source chain #184
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#184
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
find_evm_deposit_id_by_nonce_for_evminpackages/operator/src/db/mod.rsselects a pendingevm_depositsrow withWHERE nonce = $1 AND status = 'pending' LIMIT 1. Source chain and transfer hash are not in the predicate. Nonces are per source bridge, not globally unique: BSC, opBNB, MegaETH, and other EVM sources all start at the same nonce sequence.LIMIT 1with noORDER BYcan mark the wrong pending rowprocessedafter destWithdrawApprove. The deposit that was actually approved stayspending.The cosmos sibling already binds source chain.
find_evm_deposit_id_by_src_v2_chain_nonce_for_cosmosusessrc_v2_chain_id = $1 AND nonce = $2 AND dest_chain_type = 'cosmos' AND status = 'pending'. The EVM helper’s comment says it omitsdest_chain_typeon purpose (V2 poll-and-approve can dest-approve EVM→EVM without the DB-driven path). That is not a reason to omitsrc_v2_chain_id.Caller already has the missing key.
EvmWriter::sync_deposit_status_after_approval(&self, src_chain_id: &[u8; 4], nonce: u64)is invoked from enumeration and frompoll_and_approveafter dest approve succeeds. It passes onlynonceinto the EVM lookup, thenupdate_evm_deposit_status(..., "processed")andreturns. Schema already indexes(chain_id, nonce)(idx_evm_deposits_chain_nonce) and uniqueness is(chain_id, tx_hash, log_index), not global nonce.This is not #183 (RS-H2: reorg / stored
block_hashnever re-checked). This is not #182 (RS-H1: Terra confirmation depth). This is not closed #115 (RPC quorum /FINALITY_BLOCKS). This is not the cosmos helper (already scoped).Internal review id: RS-H5 (high). Still in source as of 2026-09-12 (
packages/operator/src/db/mod.rs+writers/evm.rsonmain).Bundle (same ticket, do not split):
src_v2_chain_id(required). Optionally also bindtransfer_hash/xchain_hash_idwhen the approve path has it.src_chain_idfromsync_deposit_status_after_approvalinto that helper. Do notLIMIT 1across chains. Fail closed if zero rows match; do not fall through to a different chain’s row.evm_depositssharing a nonce on different EVMsrc_v2_chain_idvalues: dest-approve for chain A must mark A processed and leave B pending.Founder-required operator dest-approve / deposit status. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Funds and operator correctness at risk today on any live operator that dest-approves EVM-origin withdrawals via V2 poll-and-approve / enumeration and then syncs DB status through this helper. Multi-EVM is the production topology (BSC, opBNB, additional EVM peers). Independent bridges assign overlapping nonces. The first matching
pendingrow wins.Wrong row marked
processed:process_evm_deposit/ pending queries that skip non-pending). User lock on B never gets dest mint/unlock through the DB path.pending. Legacy DB-driven code can try to dest-approve it again, or/statuspending_depositsstays wrong for the real transfer and lies for the stolen row.Cross-family variant of the same bug:
sync_deposit_status_after_approvaltries the nonce-only EVM lookup first and returns on any hit. A Terra-source dest-approve with nonce N can mark a pending BSC row with nonce Nprocessedand never reachfind_terra_deposit_id_by_nonce. Binding EVM lookup tosrc_v2_chain_idstops that fall-through.This is not permissionless theft of unrelated vault inventory by a stranger with no deposit. It is operator state corruption at dest-approve time: the wrong lock is treated as done, the right lock is left pending or skipped. Severity is high because dest mint/unlock is the payout and
processedis the gate that stops a second dest-approve. Rate limits and dest cancel windows do not restore the skipped row.Hypothetical-only if every pending-by-nonce lookup already required
src_v2_chain_id(they do not: only the cosmos helper does) or if the operator ingested a single EVM source so nonces cannot collide (not true once two EVM watchers insert pending rows). Sticky until the predicate and caller pass the source id and tests prove colliding nonces cannot cross-mark.Do not publish a mainnet two-chain same-nonce dest-approve sequence.
Cosmos helper already scoped; EVM helper is not
The EVM helper’s docstring contrasts dest type, not source chain.
src_v2_chain_idis a column on insert (insert_evm_deposit) and on pending SELECTs. It is unused here.Caller drops the source id it already has
sync_deposit_status_after_approval(src_chain_id, nonce)is called from:submit_withdraw_approvethen sync).poll_and_approvedest-approve success (WithdrawSubmit events).Both have
src_chain_idandxchain_hash_idin scope. Onlynoncereaches SQL. OnOk(Some(deposit_id))the writer setsprocessedand returns, so a Terra fallback never runs.Schema does not treat nonce as global
UNIQUE (chain_id, tx_hash, log_index). Indexidx_evm_deposits_chain_nonceis(chain_id, nonce). Two pending rows with the same nonce on differentchain_id/src_v2_chain_idare valid.LIMIT 1is undefined which id wins.Invariants
evm_depositsrow may be markedprocessedfrom V2 dest-approve sync only ifsrc_v2_chain_idmatches the approved source chain (4-byte V2 id). Nonce alone is not an identity.src_v2_chain_idmust not affect each other’s status. Dest-approve for A leaves Bpending.transfer_hash/xchain_hash_idis used as a second key, a hash mismatch must not update a different pending row with the same nonce.WithdrawApproveverification, hash words,finality_blocks, or userwithdrawSubmit. Do not drop the cosmos helper’ssrc_v2_chain_idbind. Do not make nonce globally unique in a way that rejects honest multi-EVM inserts.Constraints / guardrails
src_v2_chain_id: &[u8; 4]tofind_evm_deposit_id_by_nonce_for_evm(or replace it with a clearly named helper) and passingsrc_chain_idfromsync_deposit_status_after_approval. Keep “any dest_chain_type” unless a dest-type filter is proven safe for every V2 path that calls this sync.AND transfer_hash = $nwhen the approve path hasxchain_hash_idand the row storestransfer_hash. Do not require hash if older rows have NULL; then source+nonce must still be unique enough (fail closed if two pending rows share source+nonce).src_v2_chain_id(V2 4-byte) over nativechain_idBIGINT so BSC / opBNB / other EVM peers match the cosmos helper and the bytes the writer already holds. If some rows have NULLsrc_v2_chain_id, fail closed for those rows on this sync path rather than matching on nonce only.ORDER BY id+LIMIT 1as a “fix”. That still picks the wrong chain.ready. No public mainnet collision recipe.Relevant files
packages/operator/src/db/mod.rsfind_evm_deposit_id_by_nonce_for_evm(nonce-only); cosmos sibling already scoped;update_evm_deposit_statuspackages/operator/src/writers/evm.rssync_deposit_status_after_approvalhassrc_chain_idand ignores it; two call sites after dest approvepackages/operator/src/writers/terra.rspackages/operator/migrations/001_initial.sql(chain_id, nonce)index; nonce not globally uniqueRecommended direction
WHERE src_v2_chain_id = $1 AND nonce = $2 AND status = 'pending'(plus optionaltransfer_hash). ReturnNoneunless exactly one row matches. If two+ match, do notLIMIT 1; log and skip the status write (or match hash).src_chain_idfromsync_deposit_status_after_approvalinto the helper. Keep Terra fallback only when the EVM lookup returnsNoneandsrc_chain_idis the Terra V2 id (existing Terra gate).src_v2_chain_id(and distincttransfer_hash). Sync for chain A → only A isprocessed. Repeat for B.src_chain_id+ colliding EVM pending nonce → EVM row stayspending; Terra path may still run.processedafter dest-approve sync.Acceptance criteria
find_evm_deposit_id_by_nonce_for_evm(or replacement) requiressrc_v2_chain_id. Query must not be nonce +pendingonly.src_v2_chain_id: dest-approve sync for A marks Aprocessedand leaves Bpending.src_chain_idmatches no pending EVM row does not mark some other chain’s pending nonceprocessed.processed.find_evm_deposit_id_by_src_v2_chain_nonce_for_cosmosstays source-scoped. No contract change.Test plan (functional paths)
src_v2_chain_id+ nonceprocessedprocessed, Bpendingprocessed, A stillpending(or already processed from T2 in a separate test)pending; no status writestatusalreadyapproved/processedNone; no overwrite of a third pending rowtransfer_hashsrc_v2_chain_id+ cosmos destTest plan (attack, hack, and abuse)
Non-exploitative. Operator unit tests / local DB fixtures only. Do not use these as a mainnet recipe.
pending; dest-approve ALIMIT 1would flip)src_v2_chain_idon a pending rowLIMIT 1pick; no silent wrongprocessedVerification criteria
find_evm_deposit_id_by_nonce_for_evm(or replacement) bindssrc_v2_chain_idin SQL, not only in the Rust signature.sync_deposit_status_after_approvalpassessrc_chain_idinto the helper.WHERE nonce = $1 AND status = 'pending'without a chain or hash bind on the EVM deposit sync path.Out of scope
block_hashinclusion before dest approve.FINALITY_BLOCKSdefaults.find_terra_deposit_id_by_noncemulti-chain (single Terra source; only in scope insofar as the EVM-first fall-through in AC4).getDepositlayout / hash words.First-pass model recommendation
Recommendation: grok-high
Rationale: Security class and founder-required operator dest-approve / deposit identity (wallet / bridge funds). Composer is disallowed (High/security; not a low-risk first pass). Even though the production edit is likely
db/mod.rspluswriters/evm.rsplus tests, file count does not establish safety: a wrongprocessedwrite skips dest mint for the real lock or leaves it pending for a second dest-approve. Verify with two-chain colliding-nonce fixtures, not a live multi-EVM dest-approve.