Pay with any token: reusable DEX-routed invoice payment module #595

Closed
opened 2026-08-22 11:02:33 +00:00 by PlasticDigits · 27 comments
PlasticDigits commented 2026-08-22 11:02:33 +00:00 (Migrated from gitlab.com)

Parent

Reusable pay-with-any-token module. First consumers: community tax-token SKU unlocks (50 UST1 per SKU) and manager settings batches (50 UST1 per save of already-activated settings) (#592, #593). Other future paid protocol features (boosts, listings, donations, gated tools) must reuse this — do not fork Swap quote/execute into each feature.

Problem statement

Paid features are invoiced in a canonical asset (community SKUs: 50 UST1 each; one manager settings batch: 50 UST1 flat). Users often hold LUNC, cLUNC, USTR, or another listed CW20, not UST1. Forcing “buy UST1 on Swap, then come back” is extra txs, extra slippage windows, and copy the dApp already avoids on wrap+swap and one-sided LP.

There is no shared helper today that: takes an exact invoice (token_out, amount_out, payee, payload), lets the user pick any routable pay token, quotes a DEX route, and settles atomically so the payee receives at least the invoice amount.

Current codebase

Piece What it does Gap
Indexer GET /api/v1/route/solve Exact-in best execution (amount_in → estimated_amount_out); frozen hops excluded (#585); gem hops rejected in retail (#562) No exact-out (amount_out → min amount_in)
Router ReverseSimulateSwapOperations Exact-out on a given hop list (router/src/contract.rs); dApp helper reverseSimulateMultiHopSwap Path must already be known; reverse sim does not search alternate paths
Router ExecuteSwapOperations minimum_receive, optional to, wrap unwrap_output Sending to = payee forwards all output (can overpay). No “pay invoice + refund dust + attach CW20 hook to payee”
Swap page Exact-in UX; shared slippage 5% (#497); quoteCw20ViaRouteSolve (#501) Not an invoice widget
One-sided LP / zap (#533) Pay any token → route → provide Closest cousin, but not exact-out to a third-party contract hook
Wrap+multihop (#587) Native LUNC/USTC → cLUNC/cUSTC → router Must be composed into invoice pay when pay asset is native
Factory CreatePair fee uluna-only bank send (#276) Out of scope here (native denom, not CW20 invoice)
#592 launcher (planned) SKU price 50 UST1 to CMM treasury Must consume this module, not embed its own swapper

Payee contracts (launcher EnableFeature / CreateToken) should stay dumb: they only accept the invoice CW20 (Send with hook). Routing lives in this module.

Why needed

  1. SKU UX (#593) should pick any listed token (and wrap natives), not UST1-only.
  2. The next paid feature will otherwise copy-paste Swap internals and drift on slippage, F6 freeze, gems, wrap tax, and gas envelopes (#475).
  3. Two-step “swap then pay” is sandwichable and can leave the user with UST1 and a failed second tx.

Constraints / guardrails

  1. Invoice is exact and canonical. SKUs remain 50 UST1 on-chain. This module never re-prices the SKU in LUNC. It only acquires ≥ invoice amount of the invoice token.
  2. Atomic settlement. Swap + pay in one Cosmos tx (multi-msg and/or adapter). Do not require two user broadcasts.
  3. Payee gets exactly the invoice (preferred), not all swap output. Excess invoice token and leftover pay-token dust return to the user. Overpay-as-tip to CMM is not default.
  4. Do not upgrade pair swap math. Use existing router + pairs. No FoT balance-delta (H-01).
  5. Reuse route solver + reverse sim. Do not add a second best-execution engine. Optional indexer exact-out endpoint is a follow-up, not a v1 blocker: v1 = GET /route/solve for path (probe amount_in or topology) + router ReverseSimulateSwapOperations for amount_in, then forward sim to confirm estimated_out ≥ invoice.
  6. Slippage: same store default 5% and presets (#497 / #528). User max_in = reverse-sim offer × (1 + slippage). On-chain minimum_receive on the swap leg ≥ invoice.
  7. Retail route policy: frozen hops excluded; production gem-bridge hops rejected (P562-6). Unroutable pay token → disable pay + short reason (“No route”), not a swap error dump.
  8. Pay token = invoice token: skip router; single Send to payee (cheapest path). Default the picker to the invoice token when the wallet holds enough.
  9. Native LUNC/USTC: wrap then route (existing wrap-mapper fees + burn tax on unwrap — unwrap should not be needed when invoice is UST1 CW20).
  10. Tax-token pay asset: if pay token is the community tax CW20, apply TaxPreview extra-debit / protocol exemptions so max-in and Send amount match (#592). Do not special-case inside the launcher.
  11. Gas: new combined envelope (wrap? + N-hop swap + invoice Send) in getGasLimitForTx / terraGasRetailInventory.ts (G-RETAIL-1, #475, wrap+≥2hop #587). Native Max reserves uluna for the full combo.
  12. Do not send invoice payment to an address from URL/query without checksum allowlist. Payee comes from feature config (launcher env), not user paste of “treasury”.
  13. Adapter wasm admin (if on-chain helper exists) = same CMM/DEX governance rules as other first-party contracts; no user-set admin.
  14. Copy: “You pay ~X TOKEN (incl. DEX swap) → 50 UST1 fee”. Do not call it a “swap” as the primary CTA — CTA is Pay / Enable. One Route row, same as Swap (#158).

Relevant files

Area Paths
Quote frontend-dapp/src/utils/cw20RouteSolveQuote.ts, services/indexer/client.ts (getRouteSolve)
Reverse sim / execute frontend-dapp/src/services/terraclassic/router.ts (reverseSimulateMultiHopSwap, executeMultiHopSwap, to, minimum_receive)
Wrap combo router.ts native paths, transactions.ts, hybridSwapGas.ts, terraGasRetailInventory.ts
Slippage utils/slippageProtectionCopy.ts, stores/dex.ts
Token picker components/trade/TokenSearchSelect.tsx, utils/pairCatalogRank.ts (#562)
Zap cousin (do not fork blindly) utils/oneSidedLiquidityQuote.ts
Router on-chain smartcontracts/contracts/router/src/contract.rs, msg.rs
New frontend-dapp/src/utils/payInvoice.ts (name TBD), components/payments/PayWithAnyToken.tsx; optional smartcontracts/contracts/invoice-payer/

A. Frontend module (required)

Pure functions + one presentational card:

type Invoice = {
  invoiceToken: string // UST1 CW20
  invoiceAmount: string // raw, e.g. 50e6 per SKU × count
  payee: string         // launcher
  hookMsg: string       // base64 CreateToken / EnableFeature
}

quotePayInvoice({ invoice, payToken, slippagePercent, trader })
  → { payRaw, minInvoiceOut, operations, wrap?, routeLabel, feeEst, disableReason? }

buildPayInvoiceMsgs(...) // MsgExecuteContract[] for executeTerraContractMulti

UI: TokenSearchSelect (wallet balances + catalog), amount read-only (invoice in UST1 + “≈ pay token”), slippage chips, Route row, Network fee, Pay CTA.

Callers (#593, later features) pass Invoice only — they must not assemble router ops themselves.

User Send pay token to adapter:

PayInvoice { invoice_token, invoice_amount, payee, payload, max_spread, operations }

Adapter: swap via existing router (minimum_receive ≥ invoice_amount), Send exactly invoice_amount to payee with payload, refund remainder of invoice token + leftover pay token to info.sender.

Why not only multi-msg: one CW20 Send from the user (allowance-friendly), payee still only speaks invoice token, future contract callers can invoke the adapter without a wallet composing msgs.

Why not put this in the launcher: every future paid feature would re-implement routing.

Adapter must: allowlist router + factory; reject native-swap ops; cap hops; fail closed if payee Send returns less than invoice (should not happen for 1:1 UST1); not keep balances (sweep/refund in the same tx).

v1 acceptable fallback if adapter slips: wallet multi-msg (swap to = user, then Send invoice to payee). Document as equivalent settlement; still behind the same TS module.

C. Indexer exact-out (optional follow-up)

GET /route/solve?token_in&token_out&amount_out= minimizing amount_in. Do not block SKU ship on this if reverse-sim on the GET path is good enough for 50 UST1 size.

Acceptance criteria

  • Shared TS module + UI card; #593 (and tests) import it — no duplicate route construction in Create Token or Manage settings Save.
  • Pay with invoice token: one Send, no router.
  • Pay with another listed CW20: reverse quote + atomic swap+pay; payee balance +invoice; user keeps dust.
  • Pay with native LUNC (wrap path) when wrap env set.
  • Unroutable / frozen-only / gem-bridge: CTA disabled + short reason.
  • Slippage default 5%; minimum_receive ≥ invoice; failed max_spread humanized (#134).
  • Gas inventory row for the combo; make verify-issue-475 still green.
  • If adapter ships: unit tests for exact send, refund, unauthorized router, hop cap, insufficient output.
  • Docs: docs/frontend.md + short playbook skills/AGENTS_FRONTEND_PAY_INVOICE.md; make verify-issue-NNN.
  • #592 launcher still only accepts UST1 (or documented invoice token) — routing is not inside the launcher.

Test plan (all paths)

Quote

  • Invoice token selected + balance ≥ invoice → payRaw = invoice, 0 hops.
  • Invoice token, insufficient balance → disable (do not silently switch token).
  • Other token, 1-hop and ≥2-hop paths; reverse-sim offer then forward-sim out ≥ invoice.
  • Slippage 0.5 / 1 / 5 / custom; max_in scales; minimum_receive stays invoice (not invoice×(1−s) — the invoice is a floor).
  • trader threaded for fee-tier quotes (#245).
  • Progress/slow multihop: reuse route/solve progress (#485) or Calculating state (#484) — no fake receive.

Execute

  • Mock LCD: msg order wrap → swap → payee Send with hook; payee amount exact.
  • LocalTerra: pay SKU in UST1; pay SKU in cLUNC; pay SKU in LUNC (wrap); pay 3 SKUs (150 UST1) in USTR.
  • Swap output 50.1 UST1 → payee +50.0, user +0.1 (or adapter refund).

UI

  • Picker excludes invoice-unrelated dust; includes wrap natives when env set.
  • Disconnect: card visible, Pay gated.
  • Env launcher unset: card not used.

Attack / abuse / hack test plan

  • Sandwich / two-tx: module tests must assert a single broadcast (executeTerraContractMulti or one adapter Send). A test that counts broadcasts = 2 is a fail.
  • Overpay theft: swap to = payee with output > invoice must not be the default; assert payee received invoice not estimated_out.
  • Payee spoof: hook/payee from caller config; fuzzing UI must not take payee from query string.
  • Wrong invoice token: adapter/payee rejects non-UST1; user must not lose the swap output — atomic revert.
  • Min receive bypass: mutate msgs in unit test; minimum_receive missing → fail the builder.
  • Frozen / unlisted hop: solver exclude; do not execute.
  • Gem bridge: production retailExposeTestTokens()===false → no gem hop (#562).
  • Infinite approval: no unlimited increase_allowance to adapter/router; Send amount = payRaw only.
  • Reentrancy (adapter): cannot call back into adapter mid-swap; no leftover inventory after success.
  • Dust lockup: adapter balance of pay + invoice tokens = 0 after success (or documented sweep).
  • Slippage grief: 50% user setting allowed with existing high-slippage warn; still cannot pay below invoice.
  • Tax-token pay: extra-debit insufficient → fail closed, no partial SKU enable.
  • LCD error leak: humanize; no raw LCD in UI (#379 analogue).
  • Gas OOG: inventory fixture; wrap+2hop+send larger than swap-only.
  • Fake route: operations offer/ask mismatch vs pay token → builder reject.

Verification criteria

  • make test-frontend includes payInvoice / PayWithAnyToken tests.
  • make test-contracts if adapter crate exists.
  • make verify-issue-475 and make verify-issue-NNN.
  • Manual LocalTerra: enable a #592 SKU paying in cLUNC; launcher UST1 balance +50; user cLUNC decreased by quoted payRaw; no leftover stuck on adapter/router. Repeat for a settings batch Save (always 50 UST1) with a non-UST1 pay token and several fields in one payload.
  • #593 Create Token and Manage settings Save paid paths use this card; no second TokenSearchSelect+router copy.

Labels / owner / priority

enhancement architecture UX contracts · Owner: frontend + optional contracts adapter · Priority: P2 · Blocks: #593 paid-SKU UX and manager settings Save (can stub UST1-only until this lands, but must not ship UST1-only as final) · Related: #592 (invoice still UST1)

## Parent Reusable **pay-with-any-token** module. First consumers: community tax-token SKU unlocks (50 UST1 per SKU) and manager **settings batches** (50 UST1 per save of already-activated settings) ([#592](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/592), [#593](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/593)). Other future paid protocol features (boosts, listings, donations, gated tools) must reuse this — **do not** fork Swap quote/execute into each feature. ## Problem statement Paid features are invoiced in a **canonical asset** (community SKUs: **50 UST1** each; one manager settings **batch**: **50 UST1** flat). Users often hold LUNC, cLUNC, USTR, or another listed CW20, not UST1. Forcing “buy UST1 on Swap, then come back” is extra txs, extra slippage windows, and copy the dApp already avoids on wrap+swap and one-sided LP. There is **no** shared helper today that: takes an **exact invoice** (`token_out`, `amount_out`, `payee`, `payload`), lets the user pick **any** routable pay token, quotes a DEX route, and settles **atomically** so the payee receives **at least** the invoice amount. ## Current codebase | Piece | What it does | Gap | |-------|----------------|-----| | Indexer `GET /api/v1/route/solve` | Exact-**in** best execution (`amount_in` → `estimated_amount_out`); frozen hops excluded ([#585](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/585)); gem hops rejected in retail ([#562](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/562)) | No exact-**out** (`amount_out` → min `amount_in`) | | Router `ReverseSimulateSwapOperations` | Exact-out on a **given** hop list ([`router/src/contract.rs`](smartcontracts/contracts/router/src/contract.rs)); dApp helper [`reverseSimulateMultiHopSwap`](frontend-dapp/src/services/terraclassic/router.ts) | Path must already be known; reverse sim does not search alternate paths | | Router `ExecuteSwapOperations` | `minimum_receive`, optional `to`, wrap `unwrap_output` | Sending `to = payee` forwards **all** output (can overpay). No “pay invoice + refund dust + attach CW20 hook to payee” | | Swap page | Exact-in UX; shared slippage 5% ([#497](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/497)); `quoteCw20ViaRouteSolve` ([#501](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/501)) | Not an invoice widget | | One-sided LP / zap ([#533](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/533)) | Pay any token → route → provide | Closest cousin, but **not** exact-out to a third-party contract hook | | Wrap+multihop ([#587](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/587)) | Native LUNC/USTC → cLUNC/cUSTC → router | Must be composed into invoice pay when pay asset is native | | Factory `CreatePair` fee | **uluna-only** bank send ([#276](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/276)) | Out of scope here (native denom, not CW20 invoice) | | `#592` launcher (planned) | SKU price **50 UST1** to CMM treasury | Must **consume** this module, not embed its own swapper | Payee contracts (launcher `EnableFeature` / `CreateToken`) should stay **dumb**: they only accept the invoice CW20 (`Send` with hook). Routing lives in this module. ## Why needed 1. SKU UX (#593) should pick **any** listed token (and wrap natives), not UST1-only. 2. The next paid feature will otherwise copy-paste Swap internals and drift on slippage, F6 freeze, gems, wrap tax, and gas envelopes ([#475](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/475)). 3. Two-step “swap then pay” is sandwichable and can leave the user with UST1 and a failed second tx. ## Constraints / guardrails 1. **Invoice is exact and canonical.** SKUs remain **50 UST1** on-chain. This module never re-prices the SKU in LUNC. It only acquires ≥ invoice amount of the invoice token. 2. **Atomic settlement.** Swap + pay in **one** Cosmos tx (multi-msg and/or adapter). Do not require two user broadcasts. 3. **Payee gets exactly the invoice (preferred), not all swap output.** Excess invoice token and leftover pay-token dust return to the **user**. Overpay-as-tip to CMM is **not** default. 4. **Do not upgrade pair swap math.** Use existing router + pairs. No FoT balance-delta (**H-01**). 5. **Reuse route solver + reverse sim.** Do not add a second best-execution engine. Optional indexer exact-out endpoint is a **follow-up**, not a v1 blocker: v1 = `GET /route/solve` for path (probe `amount_in` or topology) + router `ReverseSimulateSwapOperations` for `amount_in`, then forward sim to confirm `estimated_out ≥ invoice`. 6. **Slippage:** same store default **5%** and presets ([#497](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/497) / [#528](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/528)). User `max_in` = reverse-sim offer × (1 + slippage). On-chain `minimum_receive` on the swap leg ≥ invoice. 7. **Retail route policy:** frozen hops excluded; production gem-bridge hops rejected (**P562-6**). Unroutable pay token → disable pay + short reason (“No route”), not a swap error dump. 8. **Pay token = invoice token:** skip router; single `Send` to payee (cheapest path). Default the picker to the invoice token when the wallet holds enough. 9. **Native LUNC/USTC:** wrap then route (existing wrap-mapper fees + burn tax on unwrap — unwrap should **not** be needed when invoice is UST1 CW20). 10. **Tax-token pay asset:** if pay token is the community tax CW20, apply `TaxPreview` extra-debit / protocol exemptions so max-in and Send amount match (#592). Do not special-case inside the launcher. 11. **Gas:** new combined envelope (wrap? + N-hop swap + invoice `Send`) in `getGasLimitForTx` / `terraGasRetailInventory.ts` (**G-RETAIL-1**, [#475](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/475), wrap+≥2hop [#587](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/587)). Native Max reserves uluna for the **full** combo. 12. **Do not** send invoice payment to an address from URL/query without checksum allowlist. Payee comes from feature config (launcher env), not user paste of “treasury”. 13. **Adapter wasm admin** (if on-chain helper exists) = same CMM/DEX governance rules as other first-party contracts; no user-set admin. 14. Copy: “You pay ~X TOKEN (incl. DEX swap) → 50 UST1 fee”. Do not call it a “swap” as the primary CTA — CTA is **Pay** / **Enable**. One **Route** row, same as Swap ([#158](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/158)). ## Relevant files | Area | Paths | |------|-------| | Quote | `frontend-dapp/src/utils/cw20RouteSolveQuote.ts`, `services/indexer/client.ts` (`getRouteSolve`) | | Reverse sim / execute | `frontend-dapp/src/services/terraclassic/router.ts` (`reverseSimulateMultiHopSwap`, `executeMultiHopSwap`, `to`, `minimum_receive`) | | Wrap combo | `router.ts` native paths, `transactions.ts`, `hybridSwapGas.ts`, `terraGasRetailInventory.ts` | | Slippage | `utils/slippageProtectionCopy.ts`, `stores/dex.ts` | | Token picker | `components/trade/TokenSearchSelect.tsx`, `utils/pairCatalogRank.ts` (#562) | | Zap cousin (do not fork blindly) | `utils/oneSidedLiquidityQuote.ts` | | Router on-chain | `smartcontracts/contracts/router/src/contract.rs`, `msg.rs` | | New | `frontend-dapp/src/utils/payInvoice.ts` (name TBD), `components/payments/PayWithAnyToken.tsx`; optional `smartcontracts/contracts/invoice-payer/` | ## Recommended direction ### A. Frontend module (required) Pure functions + one presentational card: ```ts type Invoice = { invoiceToken: string // UST1 CW20 invoiceAmount: string // raw, e.g. 50e6 per SKU × count payee: string // launcher hookMsg: string // base64 CreateToken / EnableFeature } quotePayInvoice({ invoice, payToken, slippagePercent, trader }) → { payRaw, minInvoiceOut, operations, wrap?, routeLabel, feeEst, disableReason? } buildPayInvoiceMsgs(...) // MsgExecuteContract[] for executeTerraContractMulti ``` UI: TokenSearchSelect (wallet balances + catalog), amount **read-only** (invoice in UST1 + “≈ pay token”), slippage chips, Route row, Network fee, Pay CTA. Callers (#593, later features) pass `Invoice` only — they must not assemble router ops themselves. ### B. On-chain `invoice-payer` adapter (recommended, not a DEX upgrade) User `Send` **pay token** to adapter: `PayInvoice { invoice_token, invoice_amount, payee, payload, max_spread, operations }` Adapter: swap via existing router (`minimum_receive ≥ invoice_amount`), `Send` **exactly** `invoice_amount` to `payee` with `payload`, refund remainder of invoice token + leftover pay token to `info.sender`. **Why not only multi-msg:** one CW20 `Send` from the user (allowance-friendly), payee still only speaks invoice token, future contract callers can invoke the adapter without a wallet composing msgs. **Why not put this in the launcher:** every future paid feature would re-implement routing. Adapter must: allowlist router + factory; reject native-swap ops; cap hops; fail closed if payee `Send` returns less than invoice (should not happen for 1:1 UST1); not keep balances (sweep/refund in the same tx). **v1 acceptable fallback** if adapter slips: wallet multi-msg (swap `to = user`, then `Send` invoice to payee). Document as equivalent settlement; still behind the same TS module. ### C. Indexer exact-out (optional follow-up) `GET /route/solve?token_in&token_out&amount_out=` minimizing `amount_in`. Do **not** block SKU ship on this if reverse-sim on the GET path is good enough for 50 UST1 size. ## Acceptance criteria - [ ] Shared TS module + UI card; #593 (and tests) import it — no duplicate route construction in Create Token or Manage settings Save. - [ ] Pay with invoice token: one `Send`, no router. - [ ] Pay with another listed CW20: reverse quote + atomic swap+pay; payee balance +invoice; user keeps dust. - [ ] Pay with native LUNC (wrap path) when wrap env set. - [ ] Unroutable / frozen-only / gem-bridge: CTA disabled + short reason. - [ ] Slippage default 5%; `minimum_receive` ≥ invoice; failed max_spread humanized ([#134](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/134)). - [ ] Gas inventory row for the combo; `make verify-issue-475` still green. - [ ] If adapter ships: unit tests for exact send, refund, unauthorized router, hop cap, insufficient output. - [ ] Docs: `docs/frontend.md` + short playbook `skills/AGENTS_FRONTEND_PAY_INVOICE.md`; `make verify-issue-NNN`. - [ ] #592 launcher still **only** accepts UST1 (or documented invoice token) — routing is not inside the launcher. ## Test plan (all paths) **Quote** - Invoice token selected + balance ≥ invoice → `payRaw = invoice`, 0 hops. - Invoice token, insufficient balance → disable (do not silently switch token). - Other token, 1-hop and ≥2-hop paths; reverse-sim `offer` then forward-sim `out ≥ invoice`. - Slippage 0.5 / 1 / 5 / custom; `max_in` scales; `minimum_receive` stays invoice (not invoice×(1−s) — the **invoice is a floor**). - `trader` threaded for fee-tier quotes ([#245](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/245)). - Progress/slow multihop: reuse route/solve progress ([#485](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/485)) or Calculating state ([#484](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/484)) — no fake receive. **Execute** - Mock LCD: msg order wrap → swap → payee `Send` with hook; payee amount exact. - LocalTerra: pay SKU in UST1; pay SKU in cLUNC; pay SKU in LUNC (wrap); pay 3 SKUs (150 UST1) in USTR. - Swap output 50.1 UST1 → payee +50.0, user +0.1 (or adapter refund). **UI** - Picker excludes invoice-unrelated dust; includes wrap natives when env set. - Disconnect: card visible, Pay gated. - Env launcher unset: card not used. ## Attack / abuse / hack test plan - **Sandwich / two-tx:** module tests must assert a **single** broadcast (`executeTerraContractMulti` or one adapter `Send`). A test that counts broadcasts = 2 is a fail. - **Overpay theft:** swap `to = payee` with output > invoice must **not** be the default; assert payee received **invoice** not `estimated_out`. - **Payee spoof:** hook/`payee` from caller config; fuzzing UI must not take payee from query string. - **Wrong invoice token:** adapter/payee rejects non-UST1; user must not lose the swap output — atomic revert. - **Min receive bypass:** mutate msgs in unit test; `minimum_receive` missing → fail the builder. - **Frozen / unlisted hop:** solver exclude; do not execute. - **Gem bridge:** production `retailExposeTestTokens()===false` → no gem hop ([#562](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/562)). - **Infinite approval:** no unlimited `increase_allowance` to adapter/router; `Send` amount = `payRaw` only. - **Reentrancy (adapter):** cannot call back into adapter mid-swap; no leftover inventory after success. - **Dust lockup:** adapter balance of pay + invoice tokens = 0 after success (or documented sweep). - **Slippage grief:** 50% user setting allowed with existing high-slippage warn; still cannot pay below invoice. - **Tax-token pay:** extra-debit insufficient → fail closed, no partial SKU enable. - **LCD error leak:** humanize; no raw LCD in UI ([#379](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/379) analogue). - **Gas OOG:** inventory fixture; wrap+2hop+send larger than swap-only. - **Fake route:** operations offer/ask mismatch vs pay token → builder reject. ## Verification criteria - `make test-frontend` includes `payInvoice` / `PayWithAnyToken` tests. - `make test-contracts` if adapter crate exists. - `make verify-issue-475` and `make verify-issue-NNN`. - Manual LocalTerra: enable a #592 SKU paying in cLUNC; launcher UST1 balance +50; user cLUNC decreased by quoted `payRaw`; no leftover stuck on adapter/router. Repeat for a settings **batch** Save (always 50 UST1) with a non-UST1 pay token and several fields in one payload. - #593 Create Token and Manage settings Save paid paths use this card; no second TokenSearchSelect+router copy. ## Labels / owner / priority `enhancement` `architecture` `UX` `contracts` · Owner: frontend + optional contracts adapter · Priority: **P2** · Blocks: #593 paid-SKU UX and manager settings Save (can stub UST1-only until this lands, but must not ship UST1-only as final) · Related: #592 (invoice still UST1)
PlasticDigits commented 2026-08-22 11:02:33 +00:00 (Migrated from gitlab.com)

marked as related to #592

marked as related to #592
PlasticDigits commented 2026-08-22 11:02:34 +00:00 (Migrated from gitlab.com)

marked as related to #593

marked as related to #593
PlasticDigits commented 2026-08-22 11:03:00 +00:00 (Migrated from gitlab.com)

mentioned in issue #592

mentioned in issue #592
PlasticDigits commented 2026-08-22 11:03:02 +00:00 (Migrated from gitlab.com)

mentioned in issue #593

mentioned in issue #593
PlasticDigits commented 2026-08-22 11:05:08 +00:00 (Migrated from gitlab.com)

Second consumer besides SKU unlock: manager settings changes (#592 / #593) — invoice . Same module, different hook payload.

Second consumer besides SKU unlock: **manager settings changes** (#592 / #593) — invoice . Same module, different hook payload.
PlasticDigits commented 2026-08-22 11:05:40 +00:00 (Migrated from gitlab.com)

Settings-change invoices are the same 50 UST1 unit as SKU unlocks (N settings → N*50 UST1). Same #595 module; different hook payload. See #592 / #593.

Settings-change invoices are the same 50 UST1 unit as SKU unlocks (N settings → N*50 UST1). Same #595 module; different hook payload. See #592 / #593.
PlasticDigits commented 2026-08-22 11:06:12 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
PlasticDigits commented 2026-08-22 11:09:02 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
PlasticDigits commented 2026-08-22 11:09:06 +00:00 (Migrated from gitlab.com)

Manager settings invoice is 50 UST1 per batch save (already-activated settings only), not 50 times N fields.

Manager settings invoice is 50 UST1 per batch save (already-activated settings only), not 50 times N fields.
PlasticDigits commented 2026-08-22 12:26:36 +00:00 (Migrated from gitlab.com)

mentioned in issue #597

mentioned in issue #597
PlasticDigits commented 2026-08-22 12:26:37 +00:00 (Migrated from gitlab.com)

marked as related to #597

marked as related to #597
PlasticDigits commented 2026-08-22 12:26:50 +00:00 (Migrated from gitlab.com)

Next consumer (after community SKUs): monthly market-making subscription invoice in https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/597 (prepaid UST1 to CMM; no pull-debit). Do not fork a second Swap-into-pay path.

Next consumer (after community SKUs): monthly **market-making subscription** invoice in https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/597 (prepaid UST1 to CMM; no pull-debit). Do not fork a second Swap-into-pay path.
PlasticDigits commented 2026-08-23 03:09:51 +00:00 (Migrated from gitlab.com)

mentioned in commit 995b85039c

mentioned in commit 995b85039cfc8d14f5401e766f013d116e4c6642
PlasticDigits commented 2026-08-23 03:10:03 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1117

mentioned in merge request !1117
PlasticDigits commented 2026-08-23 03:10:04 +00:00 (Migrated from gitlab.com)

Implemented on issue-595-pay-invoice (MR incoming). v1 is the shared TS module + card + multi-msg settlement (adapter wasm deferred).

Acceptance (this MR)

  • Shared TS module + UI card (payInvoice.ts, PayWithAnyToken) — #593 must import it when those pages land
  • Pay with invoice token: one Send, no router
  • Pay with another listed CW20: reverse quote + atomic swap+pay; payee Send = invoice; user keeps dust (to = user)
  • Pay with native LUNC (wrap path) when wrap env set
  • Unroutable / frozen-only / gem-bridge: CTA disabled + “No route”
  • Slippage default 5%; minimum_receive ≥ invoice; LCD errors go through existing humanize (#134)
  • Gas inventory row + wrap+2hop+invoice combo; make verify-issue-475 still green
  • Docs: docs/frontend.md I595-1–I595-14 + skills/AGENTS_FRONTEND_PAY_INVOICE.md + make verify-issue-595
  • #592 launcher still only accepts invoice CW20 — routing is not inside the launcher (documented I595-14)

Not in this MR (follow-ups)

  • On-chain invoice-payer adapter (recommended, not a v1 blocker)
  • Indexer exact-out GET /route/solve?amount_out= (optional)
  • #593 Create Token / Manage Save wiring (blocked pages; they must import this card)
  • Manual LocalTerra SKU / settings-batch txs (needs #592 launcher)
Implemented on `issue-595-pay-invoice` (MR incoming). v1 is the shared TS module + card + multi-msg settlement (adapter wasm deferred). **Acceptance (this MR)** - [x] Shared TS module + UI card (`payInvoice.ts`, `PayWithAnyToken`) — #593 must import it when those pages land - [x] Pay with invoice token: one `Send`, no router - [x] Pay with another listed CW20: reverse quote + atomic swap+pay; payee Send = invoice; user keeps dust (`to` = user) - [x] Pay with native LUNC (wrap path) when wrap env set - [x] Unroutable / frozen-only / gem-bridge: CTA disabled + “No route” - [x] Slippage default 5%; `minimum_receive` ≥ invoice; LCD errors go through existing humanize (#134) - [x] Gas inventory row + wrap+2hop+invoice combo; `make verify-issue-475` still green - [x] Docs: `docs/frontend.md` I595-1–I595-14 + `skills/AGENTS_FRONTEND_PAY_INVOICE.md` + `make verify-issue-595` - [x] #592 launcher still only accepts invoice CW20 — routing is not inside the launcher (documented I595-14) **Not in this MR (follow-ups)** - [ ] On-chain `invoice-payer` adapter (recommended, not a v1 blocker) - [ ] Indexer exact-out `GET /route/solve?amount_out=` (optional) - [ ] #593 Create Token / Manage Save wiring (blocked pages; they must import this card) - [ ] Manual LocalTerra SKU / settings-batch txs (needs #592 launcher)
PlasticDigits commented 2026-08-23 03:27:40 +00:00 (Migrated from gitlab.com)

mentioned in commit 276423e6b8

mentioned in commit 276423e6b8a5fac236bf3a2de22819b578d08652
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-23 03:27:40 +00:00
PlasticDigits commented 2026-08-23 03:29:58 +00:00 (Migrated from gitlab.com)

mentioned in commit 328b2b8d6d

mentioned in commit 328b2b8d6d365581d73fa3385749e7e8cff26f4c
PlasticDigits commented 2026-08-23 03:31:19 +00:00 (Migrated from gitlab.com)

Merge review (!1117 → main)

Merged as https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/399 (276423e6). GitLab CI jobs failed immediately with ci_quota_exceeded (not a test failure); local sanity after merge: 279 Vitest cases green (invoice quote/msgs/card + retail gas inventory + #599 envelope suite).

Acceptance vs this MR

AC Result
Shared TS module + PayWithAnyToken card Met (payInvoice.ts / PayWithAnyToken.tsx, I595-1–I595-14)
Invoice token = one Send, no router Met (unit)
Other CW20: reverse quote + atomic swap+pay; payee Send = invoice; to = user Met (unit + builder asserts)
Native LUNC wrap path when wrap env set Met (unit)
Unroutable / frozen / gem-bridge → CTA disabled + “No route” Met (unit)
Slippage default 5%; minimum_receive ≥ invoice Met (unit)
Gas inventory + wrap+2hop+invoice combo; verify-issue-475 still in tree Met (inventory fixture wrap_plus_2hop_plus_invoice_send)
Docs + make verify-issue-595 Met (script + skill on main)
#592 launcher still invoice-CW20 only Documented (I595-14); not chain-proven here
#593 Create Token / Manage Save import this card Not in this MR — still required on #593
On-chain invoice-payer adapter Deferred (not a v1 blocker)
Indexer exact-out amount_out= Deferred (optional)
LocalTerra SKU / settings-batch txs Not run — needs #592 launcher

No code defect found in the v1 module. Remaining work is consumer wiring and chain QA, already tracked by #593 / #592 / #597 — no extra issue opened for those.

## Merge review (!1117 → `main`) Merged as https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/399 (`276423e6`). GitLab CI jobs failed immediately with `ci_quota_exceeded` (not a test failure); local sanity after merge: **279** Vitest cases green (invoice quote/msgs/card + retail gas inventory + #599 envelope suite). ### Acceptance vs this MR | AC | Result | |----|--------| | Shared TS module + `PayWithAnyToken` card | Met (`payInvoice.ts` / `PayWithAnyToken.tsx`, I595-1–I595-14) | | Invoice token = one `Send`, no router | Met (unit) | | Other CW20: reverse quote + atomic swap+pay; payee Send = invoice; `to` = user | Met (unit + builder asserts) | | Native LUNC wrap path when wrap env set | Met (unit) | | Unroutable / frozen / gem-bridge → CTA disabled + “No route” | Met (unit) | | Slippage default 5%; `minimum_receive` ≥ invoice | Met (unit) | | Gas inventory + wrap+2hop+invoice combo; `verify-issue-475` still in tree | Met (inventory fixture `wrap_plus_2hop_plus_invoice_send`) | | Docs + `make verify-issue-595` | Met (script + skill on `main`) | | #592 launcher still invoice-CW20 only | Documented (I595-14); not chain-proven here | | #593 Create Token / Manage Save import this card | **Not in this MR** — still required on #593 | | On-chain `invoice-payer` adapter | Deferred (not a v1 blocker) | | Indexer exact-out `amount_out=` | Deferred (optional) | | LocalTerra SKU / settings-batch txs | **Not run** — needs #592 launcher | No code defect found in the v1 module. Remaining work is consumer wiring and chain QA, already tracked by #593 / #592 / #597 — no extra issue opened for those.
PlasticDigits commented 2026-08-23 03:31:20 +00:00 (Migrated from gitlab.com)

mentioned in issue #599

mentioned in issue #599
PlasticDigits commented 2026-08-23 03:50:24 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1119

mentioned in merge request !1119
PlasticDigits commented 2026-08-23 04:56:22 +00:00 (Migrated from gitlab.com)

mentioned in issue #602

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

mentioned in issue #558

mentioned in issue #558
PlasticDigits commented 2026-08-23 06:48:28 +00:00 (Migrated from gitlab.com)

mentioned in issue #605

mentioned in issue #605
PlasticDigits commented 2026-08-23 06:48:30 +00:00 (Migrated from gitlab.com)

marked as related to #605

marked as related to #605
PlasticDigits commented 2026-08-23 11:49:45 +00:00 (Migrated from gitlab.com)

mentioned in issue #606

mentioned in issue #606
PlasticDigits commented 2026-08-23 11:49:47 +00:00 (Migrated from gitlab.com)

marked as related to #606

marked as related to #606
PlasticDigits commented 2026-08-26 04:19:24 +00:00 (Migrated from gitlab.com)

mentioned in issue #669

mentioned in issue #669
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/cl8y-dex-terraclassic#595
No description provided.