feat: CW20 InstantWithdraw + spender registry for ust1-window (vFDUSD) #6

Closed
opened 2026-07-30 08:38:58 +00:00 by PlasticDigits · 11 comments
PlasticDigits commented 2026-07-30 08:38:58 +00:00 (Migrated from gitlab.com)

Summary

Add a governance-registered CW20 InstantWithdraw path on the CMM Treasury so ust1-window can redeem UST1→vFDUSD by asking the treasury to Transfer vFDUSD to the user — without CW20 IncreaseAllowance (treasury is a contract, not an EOA).

This is the treasury half of Option 3 (window calls treasury API). Companion issue: PlasticDigits/ust1-window (to be linked after creation). Related: Phase 3 wrap migrate #5; UST1 mainnet deploy ust1-window#19.


Current codebase

Component Path Behavior today
Treasury execute surface contracts/contracts/treasury/src/msg.rs CW20 outflows only via timelocked ProposeWithdraw / ExecuteWithdraw (Cw20ExecuteMsg::Transfer). Hot path InstantWithdraw { recipient, denom, amount } is native-only.
Native wrapper registry state.rs → DENOM_WRAPPERS SetDenomWrapper / RemoveDenomWrapper; caller must match registered wrapper for that denom.
InstantWithdraw impl contract.rs → execute_instant_withdraw Bank balance check + BankMsg::Send. No CW20 branch.
Pause SetWrappingPaused / config.wrapping_paused Gates WrapDeposit and native InstantWithdraw.
Design note (unimplemented) plans/NATIVE_TOKEN_WRAPPING.md §1.4 Sketched InstantWithdraw { recipient, asset: AssetInfo, amount } with CW20 Transfer — not what shipped (denom: String only).
Asset helpers contracts/packages/common/src/asset.rs AssetInfo::{Native, Cw20} available for new msgs if desired.
Live mainnet terra16j5u6ey7a84g40sr3gd94nzg5w5fm45046k9s2347qhfpwm5fr6sem3lr2 Code 10673 (pre-wrap per #5); gov terra1xsecn4snv94ezcez0z3vq8an9j4h4kxxcydp8l; swap_contract: null.

Why Option 2 (CW20 allowance) was rejected: Treasury cannot be --from for increase_allowance on vFDUSD; there is no gov message that emits CW20 allowance. Window withdraw currently expects TransferFrom(owner=treasury).


Why this is needed

  1. ust1-window deposits already Transfer bridged vFDUSD into this treasury address.
  2. Withdraws must return vFDUSD to users atomically with UST1 burn — 7-day ProposeWithdraw is unusable for a swap window.
  3. Native InstantWithdraw cannot move CW20 balances.
  4. Product decision: Option 3 — mirror the wrap-mapper pattern (registered contract pulls inventory) for vFDUSD, rather than bolting on CW20 allowances.

Without this, mainnet UST1→vFDUSD redeem stays blocked even after oracle/window instantiate (#19 Phase 5).


Constraints / guardrails

  1. Do not break native wrap path (SetDenomWrapper, WrapDeposit, native InstantWithdraw) — coordinate with / layer on #5 migrate.
  2. Do not re-enable USTC→USTR SwapDeposit product; leave swap_contract null.
  3. CW20 InstantWithdraw caller must be explicitly registered for that token (gov-only set/remove). Unregistered callers always fail.
  4. wrapping_paused must not gate CW20 InstantWithdraw used by UST1 window (pausing wraps must not halt vFDUSD redeem). Use a separate pause flag for the CW20 pull path (or document that only a dedicated pause applies).
  5. Timelocked ProposeWithdraw remains the only path for arbitrary destinations / non-registered spenders.
  6. Solvency: CW20 InstantWithdraw must check treasury token balance ≥ amount before emitting Transfer (fail cleanly).
  7. Prefer non-breaking message shape: keep existing InstantWithdraw { denom } for wrap-mapper; add parallel CW20 msgs (avoid forcing wrap-mapper ABI churn).
  8. Migrate in-place when possible so address terra16j5u6… stays stable (window + docs already point here).
  9. No unbounded “execute arbitrary WasmMsg” from governance — only typed allowance-free pull for registered spenders.
  10. Optional but recommended: gov-set per-spender or per-token pull cap (e.g. align with 10,000 vFDUSD window inventory policy) — if omitted in v1, document that window limits are the only cap and registered spender can drain full token balance (same as native InstantWithdraw today).

Relevant files

  • contracts/contracts/treasury/src/msg.rs — new execute/query variants
  • contracts/contracts/treasury/src/state.rs — CW20 spender map (+ optional pause/cap)
  • contracts/contracts/treasury/src/contract.rs — handlers, migrate if storage keys added
  • contracts/contracts/treasury/src/error.rs — auth / pause / balance errors
  • contracts/packages/common/src/asset.rs — reuse if msgs take AssetInfo
  • plans/NATIVE_TOKEN_WRAPPING.md — update to document CW20 InstantWithdraw as separate from native wrap
  • docs/CONTRACTS.md, docs/ARCHITECTURE.md, docs/DEPLOYMENT.md — operator docs for register/pause/migrate
  • Consumer (out of repo): ust1-window withdraw path (companion issue)

Add a parallel CW20 pull API (do not change wrap-mapper’s InstantWithdraw { denom } signature):

# Governance
SetCw20Spender { token: String, spender: String }
RemoveCw20Spender { token: String }
SetCw20InstantWithdrawPaused { paused: bool }   # independent of wrapping_paused

# Registered spender only (info.sender == CW20_SPENDERS[token])
InstantWithdrawCw20 {
  recipient: String,
  token: String,
  amount: Uint128,
}

# Queries
Cw20Spenders {}  # or Spender { token }

Implementation sketch:

  1. Validate gov for set/remove/pause.
  2. On InstantWithdrawCw20: reject if paused; load spender for token; require info.sender == spender; validate recipient; query CW20 balance of treasury ≥ amount; emit Cw20ExecuteMsg::Transfer { recipient, amount }.
  3. Unit + multitest coverage; then store + migrate live treasury (same migrate train as #5 if timing aligns, or follow-up migrate).
  4. Mainnet wiring (ops, after window ready): SetCw20Spender { token: TERRA_VFDUSD, spender: WINDOW_ADDR }.

Out of scope for this issue: changing ust1-window (companion repo); native wrap feature work (#5) except migrate coordination / non-regression.


Acceptance criteria

  • New msgs compile; schema/docs updated.
  • Existing native wrap InstantWithdraw / SetDenomWrapper tests still pass unchanged in behavior.
  • Gov can register/remove a CW20 spender for a token; non-gov cannot.
  • Registered spender can InstantWithdrawCw20 → recipient receives CW20; treasury balance decreases by amount.
  • Unregistered / wrong-token spender calls fail with clear unauthorized error.
  • CW20 pull pause blocks InstantWithdrawCw20; does not require wrapping_paused=false.
  • wrapping_paused=true does not block InstantWithdrawCw20.
  • Insufficient CW20 balance fails without partial transfer.
  • Zero amount rejected.
  • ProposeWithdraw / ExecuteWithdraw for CW20 still work (timelock path unchanged).
  • Migrate from current mainnet bytecode preserves governance, whitelist, pending withdrawals, and (post-#5) denom wrappers.
  • Docs describe register → window redeem flow and pause semantics.
  • Cross-link companion ust1-window issue; note mainnet SetCw20Spender as post-deploy step for #19.

Test plan — functional paths

# Path Expect
T1 Gov SetCw20Spender Mapping stored; query lists spender
T2 Gov RemoveCw20Spender Mapping cleared; InstantWithdrawCw20 fails
T3 Spender InstantWithdrawCw20 happy path CW20 Transfer submsg; balances update
T4 Replace spender (set new address) Old spender fails; new succeeds
T5 Two tokens, two spenders Isolation — spender A cannot pull token B
T6 Pause on / off Pause blocks pull; unpause restores
T7 Native InstantWithdraw regression Unaffected by CW20 pause and CW20 registry
T8 wrapping_paused on Native InstantWithdraw blocked; CW20 InstantWithdraw still works
T9 ProposeWithdraw CW20 still timelocked Unrelated to spender registry
T10 Migrate smoke (multitest or local) State preserved; new queries available
T11 Receive CW20 / AddCw20 whitelist Still works; whitelist not required for InstantWithdrawCw20 balance pull (document choice)

Test plan — attack / abuse / hack vectors

# Vector Expect
A1 Random address InstantWithdrawCw20 Unauthorized
A2 Gov EOA calls InstantWithdrawCw20 without being spender Unauthorized (gov ≠ automatic spender)
A3 Spender registered for token A pulls token B Unauthorized / not registered
A4 Spender sets recipient to self / attacker Allowed only if caller is registered — document: registering a buggy window can drain that token; gov must only register audited spenders
A5 Reentrancy / callback via CW20 Transfer No Receive hook assumed on recipient; treasury must not be mid-state vulnerable (standard Cw20 Transfer)
A6 Amount > balance Clean insufficient-balance error; no underflow
A7 Amount = 0 Reject
A8 Spoof token address / empty string Validate addr; fail
A9 Grief: register spender then remove mid-flight Subsequent pulls fail; no stuck funds beyond normal
A10 Pause bypass via ProposeWithdraw Still gov+timelock only — OK; ensure InstantWithdrawCw20 respects its own pause
A11 Malicious migrate / storage collision with denom_wrappers Use distinct storage namespace for CW20 spenders
A12 Drain via registering attacker contract Governance social/process risk — AC: only gov can SetCw20Spender; consider 7d timelock on SetCw20Spender if product wants parity with ProposeWithdraw (default recommendation: no timelock, matching SetDenomWrapper, but call out in review)

Verification criteria

  1. cargo test in treasury package (and workspace CI) green including new cases above.
  2. On LocalTerra / rebel: register mock CW20 spender → InstantWithdrawCw20 → balance proof.
  3. After mainnet migrate: Cw20Spenders query works; native wrap (if #5 done) still healthy; no regression on config / pending withdrawals.
  4. With live vFDUSD + registered window (companion issue): one small UST1 withdraw delivers vFDUSD without any CW20 allowance from treasury.
  5. Security review checklist signed off on A1–A12 (especially A4/A12).

Dependencies

  • Coordinate migrate ordering with #5 (prefer one treasury migrate that includes wrap and CW20 spender API if both land together).
  • Blocks Phase 5 withdraw smoke on ust1-window#19 until companion window issue ships + SetCw20Spender executed.
## Summary Add a **governance-registered CW20 InstantWithdraw** path on the CMM Treasury so `ust1-window` can redeem UST1→vFDUSD by asking the treasury to `Transfer` vFDUSD to the user — **without** CW20 `IncreaseAllowance` (treasury is a contract, not an EOA). This is the **treasury half** of Option 3 (window calls treasury API). Companion issue: [PlasticDigits/ust1-window](https://gitlab.com/PlasticDigits/ust1-window) (to be linked after creation). Related: Phase 3 wrap migrate [#5](https://gitlab.com/PlasticDigits2/ustr-cmm/-/issues/5); UST1 mainnet deploy [ust1-window#19](https://gitlab.com/PlasticDigits/ust1-window/-/issues/19). --- ## Current codebase | Component | Path | Behavior today | |-----------|------|----------------| | Treasury execute surface | `contracts/contracts/treasury/src/msg.rs` | CW20 outflows only via timelocked `ProposeWithdraw` / `ExecuteWithdraw` (`Cw20ExecuteMsg::Transfer`). Hot path `InstantWithdraw { recipient, denom, amount }` is **native-only**. | | Native wrapper registry | `state.rs` → `DENOM_WRAPPERS` | `SetDenomWrapper` / `RemoveDenomWrapper`; caller must match registered wrapper for that denom. | | InstantWithdraw impl | `contract.rs` → `execute_instant_withdraw` | Bank balance check + `BankMsg::Send`. No CW20 branch. | | Pause | `SetWrappingPaused` / `config.wrapping_paused` | Gates `WrapDeposit` **and** native `InstantWithdraw`. | | Design note (unimplemented) | `plans/NATIVE_TOKEN_WRAPPING.md` §1.4 | Sketched `InstantWithdraw { recipient, asset: AssetInfo, amount }` with CW20 `Transfer` — **not** what shipped (`denom: String` only). | | Asset helpers | `contracts/packages/common/src/asset.rs` | `AssetInfo::{Native, Cw20}` available for new msgs if desired. | | Live mainnet | `terra16j5u6ey7a84g40sr3gd94nzg5w5fm45046k9s2347qhfpwm5fr6sem3lr2` | Code **10673** (pre-wrap per #5); gov `terra1xsecn4snv94ezcez0z3vq8an9j4h4kxxcydp8l`; `swap_contract: null`. | **Why Option 2 (CW20 allowance) was rejected:** Treasury cannot be `--from` for `increase_allowance` on vFDUSD; there is no gov message that emits CW20 allowance. Window withdraw currently expects `TransferFrom(owner=treasury)`. --- ## Why this is needed 1. `ust1-window` deposits already **Transfer** bridged vFDUSD into this treasury address. 2. Withdraws must return vFDUSD to users atomically with UST1 burn — 7-day `ProposeWithdraw` is unusable for a swap window. 3. Native `InstantWithdraw` cannot move CW20 balances. 4. Product decision: **Option 3** — mirror the wrap-mapper pattern (registered contract pulls inventory) for vFDUSD, rather than bolting on CW20 allowances. Without this, mainnet UST1→vFDUSD redeem stays blocked even after oracle/window instantiate (#19 Phase 5). --- ## Constraints / guardrails 1. **Do not break** native wrap path (`SetDenomWrapper`, `WrapDeposit`, native `InstantWithdraw`) — coordinate with / layer on [#5](https://gitlab.com/PlasticDigits2/ustr-cmm/-/issues/5) migrate. 2. **Do not** re-enable USTC→USTR `SwapDeposit` product; leave `swap_contract` null. 3. CW20 InstantWithdraw caller must be **explicitly registered** for that token (gov-only set/remove). Unregistered callers always fail. 4. **`wrapping_paused` must not gate** CW20 InstantWithdraw used by UST1 window (pausing wraps must not halt vFDUSD redeem). Use a **separate** pause flag for the CW20 pull path (or document that only a dedicated pause applies). 5. Timelocked `ProposeWithdraw` remains the only path for **arbitrary** destinations / non-registered spenders. 6. Solvency: CW20 InstantWithdraw must check treasury token balance ≥ amount before emitting `Transfer` (fail cleanly). 7. Prefer **non-breaking** message shape: keep existing `InstantWithdraw { denom }` for wrap-mapper; add parallel CW20 msgs (avoid forcing wrap-mapper ABI churn). 8. Migrate in-place when possible so address `terra16j5u6…` stays stable (window + docs already point here). 9. No unbounded “execute arbitrary WasmMsg” from governance — only typed allowance-free pull for registered spenders. 10. Optional but recommended: gov-set **per-spender or per-token pull cap** (e.g. align with 10,000 vFDUSD window inventory policy) — if omitted in v1, document that window limits are the only cap and registered spender can drain full token balance (same as native InstantWithdraw today). --- ## Relevant files - `contracts/contracts/treasury/src/msg.rs` — new execute/query variants - `contracts/contracts/treasury/src/state.rs` — CW20 spender map (+ optional pause/cap) - `contracts/contracts/treasury/src/contract.rs` — handlers, migrate if storage keys added - `contracts/contracts/treasury/src/error.rs` — auth / pause / balance errors - `contracts/packages/common/src/asset.rs` — reuse if msgs take `AssetInfo` - `plans/NATIVE_TOKEN_WRAPPING.md` — update to document CW20 InstantWithdraw as separate from native wrap - `docs/CONTRACTS.md`, `docs/ARCHITECTURE.md`, `docs/DEPLOYMENT.md` — operator docs for register/pause/migrate - Consumer (out of repo): `ust1-window` withdraw path (companion issue) --- ## Recommended direction Add a **parallel** CW20 pull API (do not change wrap-mapper’s `InstantWithdraw { denom }` signature): ```text # Governance SetCw20Spender { token: String, spender: String } RemoveCw20Spender { token: String } SetCw20InstantWithdrawPaused { paused: bool } # independent of wrapping_paused # Registered spender only (info.sender == CW20_SPENDERS[token]) InstantWithdrawCw20 { recipient: String, token: String, amount: Uint128, } # Queries Cw20Spenders {} # or Spender { token } ``` Implementation sketch: 1. Validate gov for set/remove/pause. 2. On `InstantWithdrawCw20`: reject if paused; load spender for `token`; require `info.sender == spender`; validate recipient; query CW20 balance of treasury ≥ amount; emit `Cw20ExecuteMsg::Transfer { recipient, amount }`. 3. Unit + multitest coverage; then store + **migrate** live treasury (same migrate train as #5 if timing aligns, or follow-up migrate). 4. Mainnet wiring (ops, after window ready): `SetCw20Spender { token: TERRA_VFDUSD, spender: WINDOW_ADDR }`. **Out of scope for this issue:** changing `ust1-window` (companion repo); native wrap feature work (#5) except migrate coordination / non-regression. --- ## Acceptance criteria - [ ] New msgs compile; schema/docs updated. - [ ] Existing native wrap InstantWithdraw / SetDenomWrapper tests still pass unchanged in behavior. - [ ] Gov can register/remove a CW20 spender for a token; non-gov cannot. - [ ] Registered spender can `InstantWithdrawCw20` → recipient receives CW20; treasury balance decreases by `amount`. - [ ] Unregistered / wrong-token spender calls fail with clear unauthorized error. - [ ] CW20 pull pause blocks InstantWithdrawCw20; does **not** require `wrapping_paused=false`. - [ ] `wrapping_paused=true` does **not** block InstantWithdrawCw20. - [ ] Insufficient CW20 balance fails without partial transfer. - [ ] Zero amount rejected. - [ ] `ProposeWithdraw` / `ExecuteWithdraw` for CW20 still work (timelock path unchanged). - [ ] Migrate from current mainnet bytecode preserves governance, whitelist, pending withdrawals, and (post-#5) denom wrappers. - [ ] Docs describe register → window redeem flow and pause semantics. - [ ] Cross-link companion `ust1-window` issue; note mainnet `SetCw20Spender` as post-deploy step for #19. --- ## Test plan — functional paths | # | Path | Expect | |---|------|--------| | T1 | Gov `SetCw20Spender` | Mapping stored; query lists spender | | T2 | Gov `RemoveCw20Spender` | Mapping cleared; InstantWithdrawCw20 fails | | T3 | Spender InstantWithdrawCw20 happy path | CW20 Transfer submsg; balances update | | T4 | Replace spender (set new address) | Old spender fails; new succeeds | | T5 | Two tokens, two spenders | Isolation — spender A cannot pull token B | | T6 | Pause on / off | Pause blocks pull; unpause restores | | T7 | Native InstantWithdraw regression | Unaffected by CW20 pause and CW20 registry | | T8 | wrapping_paused on | Native InstantWithdraw blocked; CW20 InstantWithdraw still works | | T9 | ProposeWithdraw CW20 still timelocked | Unrelated to spender registry | | T10 | Migrate smoke (multitest or local) | State preserved; new queries available | | T11 | Receive CW20 / AddCw20 whitelist | Still works; whitelist not required for InstantWithdrawCw20 balance pull (document choice) | --- ## Test plan — attack / abuse / hack vectors | # | Vector | Expect | |---|--------|--------| | A1 | Random address InstantWithdrawCw20 | Unauthorized | | A2 | Gov EOA calls InstantWithdrawCw20 without being spender | Unauthorized (gov ≠ automatic spender) | | A3 | Spender registered for token A pulls token B | Unauthorized / not registered | | A4 | Spender sets recipient to self / attacker | Allowed only if caller is registered — **document**: registering a buggy window can drain that token; gov must only register audited spenders | | A5 | Reentrancy / callback via CW20 Transfer | No Receive hook assumed on recipient; treasury must not be mid-state vulnerable (standard Cw20 Transfer) | | A6 | Amount > balance | Clean insufficient-balance error; no underflow | | A7 | Amount = 0 | Reject | | A8 | Spoof token address / empty string | Validate addr; fail | | A9 | Grief: register spender then remove mid-flight | Subsequent pulls fail; no stuck funds beyond normal | | A10 | Pause bypass via ProposeWithdraw | Still gov+timelock only — OK; ensure InstantWithdrawCw20 respects its own pause | | A11 | Malicious migrate / storage collision with `denom_wrappers` | Use distinct storage namespace for CW20 spenders | | A12 | Drain via registering attacker contract | Governance social/process risk — AC: only gov can SetCw20Spender; consider 7d timelock on **SetCw20Spender** if product wants parity with ProposeWithdraw (default recommendation: **no timelock**, matching SetDenomWrapper, but call out in review) | --- ## Verification criteria 1. `cargo test` in treasury package (and workspace CI) green including new cases above. 2. On LocalTerra / rebel: register mock CW20 spender → InstantWithdrawCw20 → balance proof. 3. After mainnet migrate: `Cw20Spenders` query works; native wrap (if #5 done) still healthy; no regression on `config` / pending withdrawals. 4. With live vFDUSD + registered window (companion issue): one small UST1 withdraw delivers vFDUSD without any CW20 allowance from treasury. 5. Security review checklist signed off on A1–A12 (especially A4/A12). --- ## Dependencies - Coordinate migrate ordering with [#5](https://gitlab.com/PlasticDigits2/ustr-cmm/-/issues/5) (prefer one treasury migrate that includes wrap **and** CW20 spender API if both land together). - Blocks Phase 5 withdraw smoke on [ust1-window#19](https://gitlab.com/PlasticDigits/ust1-window/-/issues/19) until companion window issue ships + `SetCw20Spender` executed.
PlasticDigits commented 2026-07-30 08:39:35 +00:00 (Migrated from gitlab.com)

Companion window issue (Option 3 consumer): https://gitlab.com/PlasticDigits/ust1-window/-/work_items/20

Sequencing: land treasury API + migrate here first, then window migrate + SetCw20Spender { token: vFDUSD, spender: window }.

Companion window issue (Option 3 consumer): https://gitlab.com/PlasticDigits/ust1-window/-/work_items/20 Sequencing: land treasury API + migrate here first, then window migrate + `SetCw20Spender { token: vFDUSD, spender: window }`.
PlasticDigits commented 2026-07-30 08:39:38 +00:00 (Migrated from gitlab.com)

mentioned in issue #5

mentioned in issue #5
PlasticDigits commented 2026-07-31 02:33:40 +00:00 (Migrated from gitlab.com)

mentioned in commit 24062f9b13

mentioned in commit 24062f9b13db58876501a8492c143645afdb11ad
PlasticDigits commented 2026-07-31 02:34:03 +00:00 (Migrated from gitlab.com)

mentioned in merge request !27

mentioned in merge request !27
PlasticDigits commented 2026-07-31 02:34:18 +00:00 (Migrated from gitlab.com)

mentioned in commit b0b07e4ca2

mentioned in commit b0b07e4ca20210de3b2f2f994446bd7637350d9b
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-07-31 02:34:18 +00:00
PlasticDigits commented 2026-07-31 02:43:59 +00:00 (Migrated from gitlab.com)

mentioned in commit 2738a8696f

mentioned in commit 2738a8696fa1cbabb9b9f9468949fae683cb5062
PlasticDigits commented 2026-07-31 02:44:31 +00:00 (Migrated from gitlab.com)

mentioned in issue #7

mentioned in issue #7
PlasticDigits commented 2026-07-31 02:57:04 +00:00 (Migrated from gitlab.com)

mentioned in merge request !28

mentioned in merge request !28
PlasticDigits commented 2026-08-05 00:10:06 +00:00 (Migrated from gitlab.com)

mentioned in issue #8

mentioned in issue #8
PlasticDigits commented 2026-08-08 00:36:36 +00:00 (Migrated from gitlab.com)

mentioned in merge request PlasticDigits/ust1-window!23

mentioned in merge request PlasticDigits/ust1-window!23
PlasticDigits commented 2026-08-15 09:33:07 +00:00 (Migrated from gitlab.com)

mentioned in issue #10

mentioned in issue #10
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/ustr-cmm#6
No description provided.