Router minimum_receive on unwrap path checks the wrapped amount, not what the recipient actually gets after the mapper fee #469
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#469
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?
Came out of the security sweep on the router. On the
unwrap_outputpath theminimum_receiveslippage floor is checked against the pre-unwrap (wrapped) amount, so the mapper's fee gets skimmed after the guard and the recipient can land below the floor they set.Where it is:
smartcontracts/contracts/router/src/contract.rs,reply_swap_hop, the final-hop block ~349-398.Mechanism:
hop_outputis the router's CW20 balance delta of the wrapped output token (:349-351).minimum_receiveis asserted against thathop_output(:356-363).state.unwrap_outputbranch the router builds aCw20ExecuteMsg::Sendfor the fullhop_outputto the wrap-mapper withwrap_mapper::Cw20HookMsg::Unwrap { recipient }(:365-382) and fires it as a fire-and-forget message viaadd_message(:394-395).SWAP_STATEwas already removed at:354, so there's no reply/second check on what actually gets delivered.dex-common::wrap_mapperhasSetFeeBps { fee_bps }(packages/dex-common/src/wrap_mapper.rs:41) and exposesfee_bpsinConfigResponse(:71). TheUnwraphook (:48) deliversamount - feeto the recipient.So the check passes on the larger wrapped number, the mapper takes
fee_bps, and the recipient gets less thanminimum_receive— no revert.Repro:
fee_bps = 50(0.5%).unwrap_output = trueandminimum_receive = 1_000_000.1_000_000wrapped. Check at:357passes (1_000_000 < 1_000_000is false).1_000_000; mapper skims 5_000; recipient receives995_000native — below their floor, and nothing reverts.Impact is low: the shortfall is bounded by the mapper fee (
fee_bps), which is governance-controlled and expected to be small, and it only bites on the unwrap path. But it does mean theminimum_receiveguarantee is not actually honored end to end — the number the user signed off on isn't the number they can receive. If governance ever bumpsfee_bps, the gap widens silently for anyone using the unwrap path.Fix direction: apply
minimum_receiveto the delivered (post-unwrap) amount. Cleanest is to compute the expected net after the mapper fee — query the mapperConfig{}forfee_bpsand checkhop_output - fee(hop_output, fee_bps) >= minbefore sending — or, if we don't want the extra query, do theminimum_receiveassertion in a reply on the unwrap Send against the recipient's actual balance delta. The transfer (non-unwrap) path is fine as-is since there's no fee there.Filing under the #381 security-hardening umbrella (router slippage-invariant hardening).
Recommendation approved
mentioned in merge request !1003
mentioned in commit
5ebd7719e7Verification — #469 (router
minimum_receiveon unwrap path)Result: PASS — fix is on
main; no repo changes required.What was verified
minimum_receiveon unwrap path compares post–wrap-mapper net, not pre-unwrap CW20hop_outputreply_swap_hopqueries wrap-mapperConfig { fee_bps }, computesdelivered_amountvianet_after_wrap_mapper_unwrap_fee, assertsdelivered_amount >= minimum_receivebefore the unwrapSend(smartcontracts/contracts/router/src/contract.rs)fee_bps = 50,minimum_receive= wrapped hop output → swap reverts (recipient would gethop_output − fee)cargo test test_unwrap_minimum_receive_rejects_when_mapper_fee_skims_below_floorcargo test test_unwrap_minimum_receive_checked_on_post_unwrap_netcargo test test_unwrap_minimum_receive_succeeds_at_post_unwrap_nethop_outputvs floor)cargo test test_router_minimum_receive_assertiondocs/contracts-security-audit.mdR3 row;skills/AGENTS_ROUTER_MINIMUM_RECEIVE.mdmake test-contracts— 393 integration tests, 0 failedCommands run
Notes
The approved fix direction (query mapper
fee_bpsand check net before unwrapSend) is implemented. Integrators settingminimum_receiveon native-output swaps should subtract the mapper fee from simulated wrapped output (documented inskills/AGENTS_ROUTER_MINIMUM_RECEIVE.md).mentioned in issue #502