Pay with any token: reusable DEX-routed invoice payment module #595
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#595
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?
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
GET /api/v1/route/solveamount_in→estimated_amount_out); frozen hops excluded (#585); gem hops rejected in retail (#562)amount_out→ minamount_in)ReverseSimulateSwapOperationsrouter/src/contract.rs); dApp helperreverseSimulateMultiHopSwapExecuteSwapOperationsminimum_receive, optionalto, wrapunwrap_outputto = payeeforwards all output (can overpay). No “pay invoice + refund dust + attach CW20 hook to payee”quoteCw20ViaRouteSolve(#501)CreatePairfee#592launcher (planned)Payee contracts (launcher
EnableFeature/CreateToken) should stay dumb: they only accept the invoice CW20 (Sendwith hook). Routing lives in this module.Why needed
Constraints / guardrails
GET /route/solvefor path (probeamount_inor topology) + routerReverseSimulateSwapOperationsforamount_in, then forward sim to confirmestimated_out ≥ invoice.max_in= reverse-sim offer × (1 + slippage). On-chainminimum_receiveon the swap leg ≥ invoice.Sendto payee (cheapest path). Default the picker to the invoice token when the wallet holds enough.TaxPreviewextra-debit / protocol exemptions so max-in and Send amount match (#592). Do not special-case inside the launcher.Send) ingetGasLimitForTx/terraGasRetailInventory.ts(G-RETAIL-1, #475, wrap+≥2hop #587). Native Max reserves uluna for the full combo.Relevant files
frontend-dapp/src/utils/cw20RouteSolveQuote.ts,services/indexer/client.ts(getRouteSolve)frontend-dapp/src/services/terraclassic/router.ts(reverseSimulateMultiHopSwap,executeMultiHopSwap,to,minimum_receive)router.tsnative paths,transactions.ts,hybridSwapGas.ts,terraGasRetailInventory.tsutils/slippageProtectionCopy.ts,stores/dex.tscomponents/trade/TokenSearchSelect.tsx,utils/pairCatalogRank.ts(#562)utils/oneSidedLiquidityQuote.tssmartcontracts/contracts/router/src/contract.rs,msg.rsfrontend-dapp/src/utils/payInvoice.ts(name TBD),components/payments/PayWithAnyToken.tsx; optionalsmartcontracts/contracts/invoice-payer/Recommended direction
A. Frontend module (required)
Pure functions + one presentational card:
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
Invoiceonly — they must not assemble router ops themselves.B. On-chain
invoice-payeradapter (recommended, not a DEX upgrade)User
Sendpay token to adapter:PayInvoice { invoice_token, invoice_amount, payee, payload, max_spread, operations }Adapter: swap via existing router (
minimum_receive ≥ invoice_amount),Sendexactlyinvoice_amounttopayeewithpayload, refund remainder of invoice token + leftover pay token toinfo.sender.Why not only multi-msg: one CW20
Sendfrom 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
Sendreturns 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, thenSendinvoice 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=minimizingamount_in. Do not block SKU ship on this if reverse-sim on the GET path is good enough for 50 UST1 size.Acceptance criteria
Send, no router.minimum_receive≥ invoice; failed max_spread humanized (#134).make verify-issue-475still green.docs/frontend.md+ short playbookskills/AGENTS_FRONTEND_PAY_INVOICE.md;make verify-issue-NNN.Test plan (all paths)
Quote
payRaw = invoice, 0 hops.offerthen forward-simout ≥ invoice.max_inscales;minimum_receivestays invoice (not invoice×(1−s) — the invoice is a floor).traderthreaded for fee-tier quotes (#245).Execute
Sendwith hook; payee amount exact.UI
Attack / abuse / hack test plan
executeTerraContractMultior one adapterSend). A test that counts broadcasts = 2 is a fail.to = payeewith output > invoice must not be the default; assert payee received invoice notestimated_out.payeefrom caller config; fuzzing UI must not take payee from query string.minimum_receivemissing → fail the builder.retailExposeTestTokens()===false→ no gem hop (#562).increase_allowanceto adapter/router;Sendamount =payRawonly.Verification criteria
make test-frontendincludespayInvoice/PayWithAnyTokentests.make test-contractsif adapter crate exists.make verify-issue-475andmake verify-issue-NNN.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.Labels / owner / priority
enhancementarchitectureUXcontracts· 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)marked as related to #592
marked as related to #593
mentioned in issue #592
mentioned in issue #593
Second consumer besides SKU unlock: manager settings changes (#592 / #593) — invoice . Same module, different hook payload.
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.
changed the description
changed the description
Manager settings invoice is 50 UST1 per batch save (already-activated settings only), not 50 times N fields.
mentioned in issue #597
marked as related to #597
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.
mentioned in commit
995b85039cmentioned in merge request !1117
Implemented on
issue-595-pay-invoice(MR incoming). v1 is the shared TS module + card + multi-msg settlement (adapter wasm deferred).Acceptance (this MR)
payInvoice.ts,PayWithAnyToken) — #593 must import it when those pages landSend, no routerto= user)minimum_receive≥ invoice; LCD errors go through existing humanize (#134)make verify-issue-475still greendocs/frontend.mdI595-1–I595-14 +skills/AGENTS_FRONTEND_PAY_INVOICE.md+make verify-issue-595Not in this MR (follow-ups)
invoice-payeradapter (recommended, not a v1 blocker)GET /route/solve?amount_out=(optional)mentioned in commit
276423e6b8mentioned in commit
328b2b8d6dMerge review (!1117 →
main)Merged as https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/399 (
276423e6). GitLab CI jobs failed immediately withci_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
PayWithAnyTokencardpayInvoice.ts/PayWithAnyToken.tsx, I595-1–I595-14)Send, no routerto= userminimum_receive≥ invoiceverify-issue-475still in treewrap_plus_2hop_plus_invoice_send)make verify-issue-595main)invoice-payeradapteramount_out=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.
mentioned in issue #599
mentioned in merge request !1119
mentioned in issue #602
mentioned in issue #558
mentioned in issue #605
marked as related to #605
mentioned in issue #606
marked as related to #606
mentioned in issue #669