security(terra): withdraw approve ignores min_signatures #175
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#175
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
Config.min_signaturesis stored, validated at instantiate, and updated byUpdateMinSignatures, but the live V2 approve path never counts distinct operator (or admin) votes. One authorized caller setspending.approved = trueand starts the cancel window.This is not a reopen of #135 (wasm /
config.adminhandoff to the DEX 2-of-3) or closed #111 (post-upgrade matrix that only lists instantiatemin_signaturesas a checklist item). Those share the config field name. The bug isWithdrawApproveignoring the threshold.Do not retarget #170 (operator does not execute an already dest-approved Terra→EVM row) or #164 (LCD rate-limit stalling the writer). Those are off-chain operator bugs.
Bundle (same ticket, do not split): persist distinct approvers on each pending withdraw; flip
approvedonly whenapprover_count >= config.min_signatures; covermin_signatures >= 2so a single operator cannot complete approval; update the watchtower docs that currently advertise a configurable threshold.Current codebase
Approve is 1-of-N regardless of config
execute_withdraw_approveloadsCONFIG, checksOPERATORS[sender]orinfo.sender == config.admin, rejects already-executed / already-approved / reused(src_chain, nonce), then:pending.approved = truepending.approved_at = env.block.time.seconds()save_pending_and_sync_indexWITHDRAW_NONCE_USEDoperator_fundsto that callerconfig.min_signaturesis never read on this path. There is no approver set, no increment, no threshold compare.PendingWithdrawstores a booleanapprovedandapproved_at. Comment: “Whether operator has approved.” Noapproverslist / map.Threshold is real on config paths only
Instantiate in
contract.rsrejectsmin_signatures == 0ormin_signatures > operators.len().execute_update_min_signaturesre-checks0 < min_signatures <= OPERATOR_COUNT.execute_remove_operatorrefuses to drop the set below the stored threshold.Queries (
query.rs) echomin_signatures. Admin messageUpdateMinSignaturesexists inmsg.rs. Scripts document raising it (e.g.update_min_signaturesto 3 inpackages/contracts-terraclassic/scripts/README.md).Tests never exercise N≥2
packages/contracts-terraclassic/bridge/tests/instantiate helpers (includingtest_withdraw_flow.rs,integration.rs) setmin_signatures: 1and typically one operator. Unauthorized-caller coverage exists (test_withdraw_approve_requires_operator); threshold coverage does not.docs/contracts-terraclassic.mdWatchtower Security Model still claims “Configurablemin_signaturesthreshold” / “Operators submit approval transactions.” Deploy docs listmin_signaturesas “Required operator signatures” while examples instantiate1.Impact (Immunefi-style)
min_signatures: 1(deploy docs / tests), on-chain behavior matches that config. The hole is control-gap / fake N-of-M: anymin_signatures > 1(now or afterUpdateMinSignatures) is not enforced. One operator or the contract admin can complete approval, start the cancel window, takeoperator_funds, and (after delay, unless a canceler fires) allow execute/mint-unlock.config.admin. Admin is a full bypass of the operator map and of any intended threshold.PendingWithdrawshape changes). Raisingmin_signatureson an unpatched wasm does not reduce this risk.WithdrawApprove+PendingWithdrawstorage + withdraw tests + watchtower docs. Not EVM/Solana programs, not operator RPC, not frontend.Invariant that is broken: operator approval is N-of-M, where N is
config.min_signaturesand M is distinct registered operators (admin at most one vote unless a written exception). Today N is silently 1 whenever the caller is authorized.Constraints / guardrails
UpdateMinSignatures/RemoveOperatorchecks. Keepmin_signatures == 0illegal. Keep unauthorized callersUnauthorizedOperator.WithdrawAlreadyApprovedis fine after the threshold is met; before threshold, reject a second vote from the same addr).approved = true, or forwardoperator_fundsuntil the threshold is met. Decide explicitly whenWITHDRAW_NONCE_USEDis set: first distinct vote vs threshold. Prefer not paying the tip or startingapproved_aton a partial vote. Document the nonce choice so competing hashes cannot be griefed without a written reason.PendingWithdrawfield add: use#[serde(default)](or equivalent) so existing canonical rows still load. Do not break INV-TC-AW1 (ACTIVE_WITHDRAW_HASHESmembership). Approve that is still below threshold remains active (not executed/cancelled).approvedafter the window.WithdrawApprovesucceeds intoapproved=true. After this fix, N>1 deployments need N distinct txs. Out of scope to redesign the operator, but tests/docs must not claim one tx meets N≥2.ready. No public mainnet approve recipe.Relevant files
packages/contracts-terraclassic/bridge/src/execute/withdraw.rsexecute_withdraw_approvesetsapprovedwithout counting votespackages/contracts-terraclassic/bridge/src/state.rsConfig.min_signatures;PendingWithdrawhas no approver setpackages/contracts-terraclassic/bridge/src/execute/config.rsUpdateMinSignatures/RemoveOperatoralready enforce the number vs operator countpackages/contracts-terraclassic/bridge/src/contract.rsmin_signaturesthen never uses it on approvepackages/contracts-terraclassic/bridge/src/query.rsmin_signaturesas if it were live policypackages/contracts-terraclassic/bridge/tests/test_withdraw_flow.rspackages/contracts-terraclassic/bridge/tests/integration.rstest_withdraw_approve_requires_operator— extend, do not replacedocs/contracts-terraclassic.mdRecommended direction
Map<(hash, addr), bool>plus count). On each authorizedWithdrawApprove, insert sender if new; ifcount < min_signatures, persist and return attributes (approvals,required) withoutapproved=true.count >= min_signatures, keep the existing terminal-approve behavior:approved=true,approved_at, index sync, nonce used (if that is the chosen moment), tip payout policy documented (last voter vs fee_collector vs split — pick one; last-voter matching today is acceptable if documented).min_signatures: 2. Assert one operator leavesapproved == false; the second distinct operator flips it; a third call isWithdrawAlreadyApproved; a non-operator still fails.docs/contracts-terraclassic.md(and any skill that repeats “one operator approve”) with the vote-count behavior. Do not rewrite the whole legacyApproveWithdrawmessage section in this ticket.Acceptance criteria
min_signatures == 2and two registered operators, a single operator (or admin acting as one voter) cannot setapprovedorapproved_at > 0. Query shows the pending row still unapproved and still in the active index.approved == true, cancel window starts once, execute remains blocked until delay.min_signatures == 1(today’s tests/deploy default) still succeeds on the first authorized approve so existing 1-of-1 deployments do not stall.UpdateMinSignatures/RemoveOperatorchecks unchanged. Raising N above 1 on patched wasm actually requires N votes.cargo test -p bridgeor the repo’s documented Terra contract suite).Verification (non-exploitative)
Do not publish a mainnet single-key approve + execute sequence. Verify in multi-test / cw-multi-test only:
min_signatures: 2, userWithdrawSubmit. First operatorWithdrawApprove→approved == false; funds still held; nonce policy matches the written choice.approved == true; further approve errors.min_signatures: 1existing withdraw-flow tests stay green (approve, cancel, uncancel, execute after delay).ACTIVE_WITHDRAW_HASHES; threshold approve stays active until cancel/execute.First-pass model recommendation
Recommendation: grok-high
Rationale: Founder-required CosmWasm bridge / wasm and operator-key / N-of-M policy. Composer is disallowed for security and for contracts, auth, keys, and wallet 2-of-3. The change is not a local three-file tweak:
withdraw.rs+PendingWithdrawstorage (+ possible serde default / migrate), config/query consistency, and new N≥2 tests. Wrong nonce or tip timing can stall withdrawals or pay a tip before the set has signed. Verify with the multi-test cases above, not a live columbus-5 approve.