security(canceler): map RPC failure to Invalid and cancel #191
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#191
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
packages/canceler/src/verifier.rsmaps a successfulgetDepositwithtimestamp = 0toVerificationResult::Invalid(watcher submits cancel) but mapsgetDepositErr, HTTP/LCD transport failure, timeout, invalid RPC URL parse, and exhausted Solana RPC fallback toVerificationResult::Pendingwith a"will retry"log.packages/canceler/src/watcher.rsthen inserts the hash intopending_retry_queue(C12) and does not callsubmit_cancel.That inverts watchtower policy. Prefer a wrongful cancel over a missed cancel. Cancel can be reversed (EVM
withdrawUncancel, Terraexecute_withdraw_uncancel, Solanawithdraw_reenable). An uncancelled fraudulent destWithdrawApprovethat executes cannot. A down or flaky source RPC therefore burns cancel-window time instead of cancelling.Internal review id: RS-H3. Policy still in source as of 2026-09-12 (
packages/canceler/src/verifier.rsonmain). The residual is inverted relative to watchtower: empty-deposit is cancel; transport failure is wait.This is not #114 / #115 (sequential RPC fallback,
eth_blockNumberquorum, confirmation depth,/healthidle,eth_chainId, HTTPS gate). Those were closed as non-launch-blocking after the 2026-04 RPC-hardening merge. Do not reopen them to add quorum-before-cancel or Pending-on-disagreement. This is not #177 (on-chain cancel-window floor). This is not #188 (pause vs dest approve). Keyword overlap on “RPC” / “cancel” / “Pending” is not this bug.Bundle (same ticket, do not split):
Invalidand submit cancel on EVM, Terra, and Solana deposit verify.timestamp = 0, Terradata: null, Solana missing PDA) asInvalid.Pendingremains only for unknown source chain (C6, including unknown EVM V2 id inknown_evm_chains).getDeposittransport error submits cancel; uncancel still restores.Founder-required canceler / wallet watchtower. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Funds at risk today in source whenever dest approval verification cannot reach a healthy source RPC inside the cancel window.
Invalidis the only verifier result that submits cancel.Pendingretries. A fraudulent or mismatched approval whose sourcegetDeposit(or TerraDepositHash/ SolanagetAccountInfo) errors, times out, or disagrees across providers is treated like “try later,” not fraud.Asymmetric mapping already in
verify_evm_deposit:Ok(deposit)anddeposit.timestamp.is_zero()→Invalid(“No deposit found…”) → cancel.Err(e)→Pending("Failed to query EVM deposit - will retry").The same wait path exists for:
parsefailure ("Invalid EVM RPC URL").Address::from_strfailure ("Invalid EVM bridge address").send()Err("Failed to query Terra deposit - will retry").Pending).Pendingafter the fallback loop.solana_configisNone→Pending.C12 queues
Pendingfor retry. There is no verifier path that promotes a persistent transport error toInvalidbefore the dest cancel window elapses. If the source RPC stays down or rate-limits for the window, dest execute can proceed on an unverified approval.Hypothetical-only if every production source RPC is always reachable, always agrees, and the cancel window is longer than any outage. Source still encodes wait-on-error. Sticky until transport / timeout / disagreement are
Invalidand a unit test proves agetDeposittransport error submits cancel.Do not publish a live-RPC recipe, a fraudulent approve payload, or operator key material.
Watchtower invariant (why wait is the vuln)
Cancel is reversible by the operator. Execute of an uncancelled fraudulent approval is not. Multi-RPC quorum or Pending-on-disagreement that delays cancel is the wrong control for this service. #114 EVM-01 taught sequential fallback / quorum for block reads; that must not be reused here as “do not cancel until providers agree.”
C6 must stay Pending
Unknown
src_chain_id(not in configured EVM / Terra / Solana sets) must remainPending, notInvalid. ReturningInvalidthere cancels every valid approval when V2 chain ids are misconfigured (catastrophic false-positive). Hash mismatch on a known chain staysInvalid(parameters do not match claimed hash). That split is already documented inverifier.rsC6 comments andtest_c6_unknown_chain_returns_pending.Invariants
VerificationResult::Invalid. Watcher mustsubmit_cancel(same path as missing-deposit).timestamp = 0, Terra nulldata, Solana null PDA / stored-hash mismatch) stayInvalid→ cancel.known_evm_chains) staysPending. No cancel. Alert /unknown_source_chain_countunchanged in spirit.Invalid.withdrawUncancel, Terraexecute_withdraw_uncancel, Solanawithdraw_reenable. This ticket does not add auto-uncancel.WithdrawApproveitself, on-chain cancel-window seconds (#177), or C6. Do not put live RPC URLs, operator keys, or HMAC secrets in fixtures.Constraints / guardrails
Err/ timeout / disagreement toInvalid { reason }at the verifier. Do not add a “retry until window almost elapsed then maybe cancel” heuristic as the fix.Pending.data: nullafter a successful query. Transport / 5xx / timeout →Invalid(cancel), notPending.Invalid, notPending. Do not treat “first responsive RPC” as truth when another configured RPC reports a different deposit record.EVM_RPC_AGREEMENT_QUORUMagreement beforesubmit_cancelon this path.pending_retry_queueinstead of cancel.ready. No public fraud-approve recipe.Relevant files
packages/canceler/src/verifier.rsgetDepositErr/ URL parse / Terra LCDErr/ Solana all-RPC fail →Pending;timestamp = 0alreadyInvalid; C6 unknown chainPendingpackages/canceler/src/watcher.rsVerificationResult::Invalid→submit_cancel;Pending→pending_retry_queueonlypackages/canceler/src/evm_client.rspackages/cancelerunit tests inverifier.rswithdrawUncancel(Bridge.sol)execute_withdraw_uncancelwithdraw_reenableRecommended direction
verify_evm_deposit, mapgetDepositErr, RPC URL parseErr, and bridge address parseErrtoInvalidwith an explicit reason (transport / parse / timeout). Keeptimestamp = 0, amount mismatch, and nonce mismatch asInvalid. Keep unknown V2 id inknown_evm_chainsas C6Pending.verify_terra_deposit, map LCDsend()Err, timeout, and non-success status toInvalid. Keepdata.is_null()and nonce/amount mismatch asInvalid.verify_solana_deposit, keep trying the next URL on a single-endpoint failure. After all URLs fail, or if two successful responses disagree on PDA existence / stored hash, returnInvalid. Missingsolana_configwhile the chain id matched is not C6; treat asInvalid(cannot attest the deposit) unless product intent is that unmatched-config is equivalent to unknown chain — default is cancel.Invalid; existingsubmit_cancelpath is enough. Ensure C12 does not re-queue those hashes asPending.getDeposit/DepositHash/getAccountInfo, any disagreement isInvalid. Do not wait for quorum. Do not add a new env that pins “Pending until N providers agree.”getDeposittransport error →Invalidand watchersubmit_cancel(or a verifier+watcher unit that asserts cancel is attempted). Existing C6 test still expectsPending. Add or reuse an uncancel test that a cancelled-then-uncancelled withdrawal can proceed (operator restore). Invert any test that asserts RPCErrisPending.Acceptance criteria
getDeposittransport error / timeout →VerificationResult::Invalidand cancel is submitted (not C12Pending).Invalid(cancel), notPending.Invalid(cancel). Terradata: nullstaysInvalid.Invalid(cancel). Null PDA and stored-hash mismatch stayInvalid. Cross-endpoint disagreement on the same nonce/hash →Invalid.Invalid. Unknown source chain (C6) staysPendingand does not cancel.test_c6_unknown_chain_returns_pendingremains green.Pending.readylabel. No Given/When/Then autoland shortcut. Do not reopen #114 / #115.Test plan (functional paths)
getDepositreturnstimestamp = 0Invalid; watcher submits cancel (existing)getDeposittransport / timeout errorInvalid; cancel submitted; not inpending_retry_queueInvalid; no silent retryInvalid(unchanged)Valid; no cancelsend()error or timeoutInvalid; canceldata: nullInvalid; cancelInvalid; cancelInvalid; cancelsrc_chain_id(C6)Pending; no cancel; retry queue OKInvalid; cancelInvalid; cancel; no quorum waitTest plan (attack, hack, and abuse)
Non-exploitative. Canceler unit / in-process mocks only. Do not use these as a live dest-approve recipe.
Err, laterOkmatching depositInvalid+ cancel; uncancel is the restore if the deposit was realPending; must not mass-cancelInvalid+ cancel (disagreement); not PendingInvalid; not an infinite Pending looptimestamp = 0stillInvalid(must not flip empty-deposit to Pending)Verification criteria
verify_evm_depositno longer returnsPendingongetDepositErr/ URL parseErr.Err/ non-success and Solana all-RPC failure are not the finalPendingreturn except C6 unknown-chain.test_c6_unknown_chain_returns_pending(or equivalent) still asserts unknown chain isPending.Invalidand that cancel is attempted.Out of scope
/healthidle 503, HTTPS gate,eth_chainIdstartup checks. Do not reopen.First-pass model recommendation
Recommendation: grok-high
Rationale: Security class and founder-required watchtower / canceler path (funds-at-risk if dest execute races a Pending retry). Composer is disallowed (High/security; not a low-risk first pass). Even if the production edit is likely
verifier.rsplus focused watcher/tests, file count does not establish safety: invertingPendingvsInvalidcan either miss a fraudulent approve or mass-cancel on C6 misconfiguration. Verify with mocked RPC transport errors and the existing C6 + uncancel tests, not a live chain probe.