security(evm-solana): add on-chain M-of-N operator withdraw approve #176
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#176
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
EVM
withdrawApproveand Solanawithdraw_approveare 1-of-1. Any single authorized operator (EVM: member of_operatorsorowner(); Solana:bridge.operator) can mark a pending withdrawal approved and start the cancel window. There is no on-chain deposit proof and no M-of-N vote. The off-chain operator service signs with oneEVM_PRIVATE_KEY/SOLANA_PRIVATE_KEY.This is not #175 (Terra CosmWasm
WithdrawApproveignoresmin_signatures— sibling, keep separate). This is not #135 (wasm /config.adminhandoff to the DEX 2-of-3). Closed #115 is RPC read-consensus, not operator signing. Closed #68 is Solana QA hardening (fees, PDA seeds), not quorum.Bundle (same ticket, do not split): on-chain M-of-N operator approvals on EVM and Solana, or at minimum N-of-M signatures in the operator service before broadcast, with tests that a single operator key cannot complete approval when N≥2. Terra stays on #175.
EVM: set exists, threshold does not
Bridge.withdrawApprove(onlyOperator+nonReentrant) loadspendingWithdraws[xchainHashId], then:(srcChain, nonce)viawithdrawNonceUsedand immediately sets it usedapproved = true,approvedAt = block.timestampoperatorGastomsg.senderNo deposit lookup. No vote count.
_operatorsis already anEnumerableSet(addOperator/removeOperator/getOperators), but_onlyOperatortreats any member orowner()as sufficient.PendingWithdrawstores a booleanapproved/approvedAtonly.test_WithdrawApproveinpackages/contracts-evm/test/Bridge.t.solis a singlevm.prank(operator). Enumeration tests assumegetOperatorCount() == 1. There is no N≥2 approve fixture.Solana: one pubkey
withdraw_approve::handlerrequiresoperator.key() == bridge.operator, then setsapproved/approved_atand inits theNonceUsedPDA.BridgeConfighas a singleoperator: Pubkey.PendingWithdrawhas no approver collection.Existing tests (
non-operator cannot approve,cannot approve twice, pause) do not cover a threshold. Layout change for an operator set +min_signaturesis in scope (AnchorInitSpace/ migrate), not a follow-up.Operator service is also 1-of-1
EvmWriter::submit_withdraw_approvesendswithdrawApprovefrom onePrivateKeySigner(EVM_PRIVATE_KEY). The Solana writer uses oneSOLANA_PRIVATE_KEY. Deposit existence is checked off-chain before broadcast; a holder of the hot key skips that path. Circuit-breakerthresholdinwriters/mod.rsis consecutive RPC failure count, not signing quorum.docs/security-model.mdcurrently treats a compromised operator as a canceler-window problem (default 5 minutes). That is the only on-chain brake today.Impact (Immunefi-style)
_operators.containsorowner(). Solana: exactbridge.operator.Bridge.withdrawApprove+ Solanawithdraw_approve+ operator writers + approve tests + security-model docs. Not Terra CosmWasm (#175). Not canceler RPC. Not frontend UX.Invariant that is broken: operator approval is M-of-N (or at least N distinct hot keys before broadcast). Today M is silently 1.
Preferred fix is on-chain quorum on both chains. Off-chain N-of-M in the operator before broadcast is an allowed interim only if keys are not co-resident in one process; it does not replace the on-chain ticket.
Constraints / guardrails
cancelWindow/withdraw_delay, pause, rate limits, or(srcChain, nonce)replay. Execute still requiresapprovedafter the window.Unauthorized/UnauthorizedOperator).approved = true, or payoperatorGas/ lamports until the threshold is met. Document whenwithdrawNonceUsed/NonceUsedPDA is created: first distinct vote vs threshold. Prefer not paying the tip or startingapprovedAton a partial vote.__gap/ new mapping keyed by hash for votes; do not break existing pending rows. Solana: account resize / migrate must keep live pending PDAs loadable.withdrawApproveflipsapproved. After this, N>1 deployments need N distinct txs (or one tx carrying M signatures). Out of scope to redesign cancelers, but tests/docs must not claim one key meets N≥2.ready. No public mainnet approve recipe. Do not publish a working single-key mint/unlock sequence.Relevant files
packages/contracts-evm/src/Bridge.solwithdrawApprove1-of-1;_onlyOperatorincludes ownerpackages/contracts-evm/src/interfaces/IBridge.solPendingWithdrawhas no vote set /minSignaturespackages/contracts-evm/test/Bridge.t.solpackages/contracts-solana/programs/cl8y-bridge/src/instructions/withdraw_approve.rsbridge.operatorcheckpackages/contracts-solana/programs/cl8y-bridge/src/state/bridge.rsoperator: Pubkeypackages/contracts-solana/programs/cl8y-bridge/src/state/pending_withdraw.rsapprovedonlypackages/contracts-solana/tests/deposit_withdraw.test.tsnon-operator cannot approve; add thresholdpackages/operator/src/writers/evm.rswithdrawApprovepackages/operator/src/writers/solana.rswithdraw_approvepackages/operator/src/config.rsEVM_PRIVATE_KEY/SOLANA_PRIVATE_KEYdocs/security-model.mdRecommended direction
minSignatures(constructor / owner setter,0illegal,<= getOperatorCount()). Store distinct approvers perxchainHashId. First votes persist withoutapproved=true. Whencount >= minSignatures, keep today’s terminal-approve behavior (window, nonce policy as documented, tip). Owner is one voter unless a written exception exists.bridge.operatorwith an operator set +min_signatures(PDA list or remaining accounts). Same vote-then-threshold semantics as EVM. MigrateBridgeConfig/ pending accounts without invalidating in-flight withdraw PDAs.approved == false; second distinct voter flips it; same key twice does not; M=1 still matches today’s 1-of-1 so current deploys do not stall.docs/security-model.md(and operator docs) so “compromised operator” is not only a canceler race when M>1 is configured.Do not implement Hyperlane
MultisigValidationModulefromHYPERLANE_FUTURE_SUPPORT.mdin this ticket.Acceptance criteria
approvedorapprovedAt > 0. Pending row stays unapproved and still pending.approved == true, cancel window starts once, execute remains blocked until delay.bridge.operator(or leftover 1-key config after upgrade) cannot complete approval. Two distinct operator signers can. Unauthorized stillUnauthorizedOperator.Verification (non-exploitative)
Do not publish a mainnet single-key approve + execute sequence. Local / Anvil / surfpool / Anchor test only:
minSignatures = 2, userwithdrawSubmit. FirstwithdrawApprove→approved == false; funds still locked; nonce policy matches the written choice.approved == true; further approve reverts; execute still waits forcancelWindow.owner()as the first voter only: still not approved until a second distinct voter (unless a documented owner-override exception exists — then test that exception instead of AC1).withdraw_approve.First-pass model recommendation
Recommendation: grok-high
Rationale: Security + founder-required contracts, operator keys, and wallet / 2-of-3 policy. Composer is disallowed (security class; contracts/auth/keys; more than three production files; two chains plus operator writers; Solana account layout). Wrong nonce or tip timing can stall withdrawals or pay a tip before the set has signed. Verify with Forge + Anchor threshold tests, not a live approve.