security(terra): pending src_decimals must not change payout after approve #180
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#180
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
Terra V2
AdminFixPendingDecimalsrewritesPendingWithdraw.src_decimalson any non-executed row. Execute mint/unlock paysnormalize_decimals(amount, src_decimals, dest_decimals)from that stored field. Operator approve attests the deposit hash (amount + accounts + nonce), not the scaled payout. Cancelers watch the same hash. After they have signed off, admin can still change the scale that execute uses.This is not #175 (WithdrawApprove ignores
min_signatures). This is not #177 (timelock / 15s floor on cancel-window config). This is not #135 (wasmconfig.adminhandoff to DEX 2-of-3). Those share admin / approve / delay words. The bug is post-attestsrc_decimalsmutation plus no bind toTOKEN_SRC_MAPPINGS.Internal review ids: TERRA-H1 and TERRA-H2 (high). Still in source as of 2026-09-12.
Bundle (same ticket, do not split):
approved, require a fresh approve, resetapproved_at/ cancel window.src_decimalsto the liveTOKEN_SRC_MAPPINGSvalue for that(src_chain, src_token)(not an arbitrary0..=18).dest_decimalsvsTOKENS.terra_decimals) or revert.Founder-required CosmWasm. No community autoland. Do not add
ready.Impact (today vs hypothetical)
Funds at risk today in source if
config.adminis compromised (or already hostile). Not a permissionless drain: non-admin cannot callAdminFixPendingDecimals. After operators approve and cancelers let the window expire, execute still reads mutablepending.src_decimals. Loweringsrc_decimalsrelative todest_decimalsmultiplies payout (normalize_decimalsscales up whensrc < dest). The hash does not include decimals, so the attested deposit can pay a different Terra amount than cancelers inferred from the mapping at submit.This is theft/inflation of a pending user withdraw (mint or unlock), not a lock bug. Rate limits may trip on huge scaled amounts; they are not a control. Sticky until CosmWasm is patched (no storage-layout change required for the pre-approval restriction).
Hypothetical-only if every live wasm has never been instantiated with this message, or admin is already 2-of-3 with no single-key path. Source still wires the message and the execute path.
Do not publish a mainnet approve-then-fix-then-execute sequence.
TERRA-H1: any non-executed row, including post-approve
execute_admin_fix_pending_decimals(packages/contracts-terraclassic/bridge/src/execute/withdraw.rs):info.sender == config.adminelseUnauthorized.src_decimals > 18rejected.executed→WithdrawAlreadyExecuted.pending.src_decimals = src_decimals; save; emit old/new.No check of
pending.approved,cancelled, orapproved_at. No reset of the cancel window. Natspec says “Only allowed on approved-but-not-executed withdrawals,” which is the dangerous window and is not what the code enforces (unapproved rows are also writable). Either way, post-approval rewrite is allowed.WithdrawSubmitcopiessrc_mapping.src_decimalsandtoken_config.terra_decimalsonto the row.compute_xchain_hash_idhashes src/dest chain, accounts, token, rawamount, nonce — not decimals.execute_withdraw_approvesetsapproved/approved_atafter operator-or-admin auth and nonce checks; it does not re-read mappings or freeze scale.Cancel: canceler-only, requires
approved, withinapproved_at + WITHDRAW_DELAY. Afterwindow_end, cancel isCancelWindowExpired. Decimal fix after that point cannot be cancelled.Execute unlock and mint both call
normalize_decimals(pending.amount, pending.src_decimals, pending.dest_decimals)then rate-limit and pay thatpayout_amount.load_and_validate_executionchecks approved / not cancelled / window passed — not decimals vs mapping.TERRA-H2: unbounded value, no mapping bind, no execute check
The new
src_decimalsis anyu8≤ 18. It is not required to equalTOKEN_SRC_MAPPINGS[(src_chain, src_token)].src_decimals. AdminSetIncomingTokenMappingcan change the mapping independently; that does not rewrite existing pending rows (hence this admin message). Execute never reloads the mapping for scale.test_withdraw_flow.rscovers submit storing mapping decimals and execute with the correct 18→6 (and large-amount) conversion. There is no test ofAdminFixPendingDecimals. CI cannot catch post-approval inflation.Watchtower docs (
docs/contracts-terraclassic.md) describe operator approve + delay + canceler verify. They do not mention admin rewriting payout scale after that verify.Why the new implementation is needed
src_decimalsis payout intent. Leaving it admin-mutable after approve makes canceler review of the hash incomplete.TOKEN_SRC_MAPPINGSat submit). After approve it must re-enter the watchtower or be forbidden.0..=18without a mapping cap lets a compromised admin pick the scale that maximizes mint/unlock, independent of the registered source decimals.Constraints / guardrails
(src_chain, nonce)replay, or hash inputs. Do not add decimals into the canonical deposit hash (that would break EVM/Solana parity). Freeze or re-attest pending scale instead.!pending.approved, and reject ifcancelled/executed). Alternative: on any decimal change, setapproved = false,approved_at = 0, keep the row inACTIVE_WITHDRAW_HASHES(INV-TC-AW1), require a newWithdrawApprove(which restartsapproved_at). Pick one; do not leave a silent post-approve write.src_decimalsmust equal the current mapping’ssrc_decimals(after any intended mapping fix). Do not accept a value that is merely<= 18. If product needs to fix mapping and pending in one flow, require mapping update first, then pending copy of that same value, still pre-approval or with re-approve.normalize_decimalsmust use a scale that matches mapping +TOKENS.terra_decimals(or the frozen snapshot taken at approve). Mismatch → revert, do not pay. Same rule on unlock and mint.dest_decimalsvia this message unless the same pre-approval / re-attest + mapping/TOKENScap is applied. Today onlysrc_decimalsis writable; do not add a second unbounded knob.PendingWithdrawshape can stay; no required new field if the restriction is “unapproved only” + execute mapping check. If you snapshot scale at approve, use#[serde(default)]for any new field so canonical rows still load.ready. No public mainnet fix recipe.Relevant files
packages/contracts-terraclassic/bridge/src/execute/withdraw.rsexecute_admin_fix_pending_decimals; submit copies mapping decimals; approve ignores scale; executenormalize_decimalspackages/contracts-terraclassic/bridge/src/msg.rsExecuteMsg::AdminFixPendingDecimalspackages/contracts-terraclassic/bridge/src/contract.rspackages/contracts-terraclassic/bridge/src/execute/config.rsSetIncomingTokenMappingupdates mappingsrc_decimalswithout pending rowspackages/contracts-terraclassic/bridge/src/state.rsPendingWithdraw.{src,dest}_decimals;TOKEN_SRC_MAPPINGSpackages/contracts-terraclassic/bridge/tests/test_withdraw_flow.rsdocs/contracts-terraclassic.mdRecommended direction
AdminFixPendingDecimalsunless!approved && !executed && !cancelled. Copy onlyTOKEN_SRC_MAPPINGS.src_decimals(argument must equal mapping, or ignore the argument and copy mapping). Unapproved-only keeps cancelers from reviewing a hash whose scale later changes.src_decimalsbefore execute can succeed.pending.src_decimals != mapping.src_decimalsorpending.dest_decimals != token_config.terra_decimals, revert with a dedicated error (e.g.DecimalScaleMismatch). This also catches a mapping edit that was not copied, and a hostile copy that drifted from mapping.src_decimalsnot equal to mapping fails. Existing 18→6 execute stays green.Acceptance criteria
WithdrawApprove,AdminFixPendingDecimalswith a differentsrc_decimalsreverts (preferred) or clearsapprovedand resetsapproved_at. Execute must not pay the new scale without a new completed watchtower cycle.src_decimalsonly to the live mapping value.0..=18that does not match mapping reverts.terra_decimals. Honest submit (no admin fix) still pays the mapping conversion (existing 1e18 → 1e6 uluna fixture).Unauthorized. Executed rows stillWithdrawAlreadyExecuted. Cancel / delay / pause / nonce behavior unchanged except the chosen re-attest reset.execute_admin_fix_pending_decimalsmust match the shipped restriction (today it describes the unsafe window).cargo test -p bridgeor the repo’s documented Terra contract suite).Test plan (functional paths)
src_decimals == 18,dest_decimals == 6AdminFixPendingDecimalsto mapping 18 (no-op)SetIncomingTokenMappingcorrected 18→60while mapping is 18src_decimalsWithdrawNotApprovedor window active)UnauthorizedWithdrawAlreadyExecutedDecimalScaleMismatch(or equivalent)Test plan (attack, hack, and abuse)
Non-exploitative. cw-multi-test / local only. Do not use these as a mainnet recipe.
src_decimalssonormalize_decimalsscales up, wait original window, executesrc_decimalsto shrink user payoutTOKEN_SRC_MAPPINGSafter approve without pending fix, executeapproved_atsrc_decimals = 0(≤18) with dest 6Verification criteria
cargo test -p bridge(or documented Terra suite): T1–T10 and A1–A6 intest_withdraw_flow.rs(or a dedicated decimals test module).execute_admin_fix_pending_decimalschecksapproved/ mapping equality (or the documented re-attest). Execute mint and unlock both compare scale to mapping beforenormalize_decimalspayout.AdminFixPendingDecimalson columbus-5.Out of scope
approved_at; it must not retarget delay governance).RecoverAsset/LOCKED_BALANCES.src_decimals, but the contract must not require them to win a race after the window).First-pass model recommendation
Recommendation: grok-high
Rationale: Security class plus founder-required CosmWasm (
withdraw.rspayout scale,PendingWithdrawattestation vs execute, mapping bind, watchtower window). Composer is disallowed (High/security; contracts / wasm / admin keys / wallet 2-of-3). Not a local three-file tweak: execute mint and unlock, mapping cap, tests, natspec/docs. A wrong allow (post-approve write without resettingapproved_at) leaves canceler review of the hash meaningless. Verify with multi-test approve-then-fix fixtures, not a live columbus-5 execute.