feat(tokens): gasless GToken/aPNTs purchase page + SDK 0.26.4#357
Conversation
Refactor /sale into /tokens — a frictionless buy page for the two Mycelium
tokens, mirroring launch.mushroom.cv/join but driven by the SDK's
@aastar/sdk/tokens TokenSaleClient (no hand-rolled ABI/EIP-712):
- Gasless (USDC): EIP-3009 TransferWithAuthorization + BuyIntent → relayer
pays gas (buyGasless). Default recipient = the user's AirAccount.
- Self-pay (USDC/USDT): approve + buy, user pays ETH gas (buySelfPay).
- Smart default (low ETH → gasless), live quote, balances, MetaMask connect +
Sepolia switch. WETH/WBTC shown as "soon" (needs oracle).
- Positioning: GToken = Digital Public Goods ticket (governance); aPNTs =
Utility Computing Unit that powers gasless service.
Also:
- /sale → /tokens route + Layout menu label
- paymaster page: banner linking to /tokens ("GToken (ticket)")
- tokens page fully bilingual (en/zh tokensPage namespace); i18n parity OK
- lib/sdk/client: bind account on connectWallet, add ensureChain +
buildTokenSaleClient
Deps: @aastar/sdk ^0.24.2 → ^0.26.4.
- 0.26.3 fixed the stale Sepolia PaymasterV4 address (0x1f0D… → 0x9578…,
sourced from canonical so the paymaster preset auto-updates).
- 0.26.4 made viem a peerDependency (aastar-sdk#157), so the SDK's types
resolve against the app's own viem — removed the prior `as any` cast.
Lockfile shrinks ~1200 lines: the nested viem 2.43.3 subtree under
@aastar/sdk is correctly dropped now that viem is a peer. npm ci verified
(next intact), type-check + build green both workspaces.
Deferred (tracked): self-pay recipient → AirAccount (launch#21 + aastar-sdk#145),
USDT→AirAccount sponsored buy via SuperPaymaster (SuperPaymaster#300).
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…y cast The clean 'viem as peer' install (npm install --legacy-peer-deps) drops the nested viem subtree but reshuffles the react/next tree so 'next dev' fails with Cannot find module 'react'. Use a surgical 0.26.4 bump over the proven 0.26.3 lockfile (version/resolved/integrity only) — next dev + npm ci verified. Since the nested viem 2.43.3 stays, the TokenSaleClient generic drift remains, so the buildTokenSaleClient boundary cast is retained (cleaning the nesting is a separate lockfile-hygiene follow-up).
…y cast Root cause of the TokenSaleClient type drift: two viem versions in the tree (backend pinned viem 2.43.3, frontend ^2.47.6), so npm kept a nested viem 2.43.3 under @aastar/sdk and the SDK's (now peerDependency) types resolved against the wrong copy. Industry-standard fix — converge on one viem version: - aastar (backend): viem 2.43.3 → ^2.47.6 (the SDK peer is >=2.43 <3, compatible) - root overrides: force a single viem 2.47.6 + its ox 0.14.7 (was a stale 0.11.1 pin for viem 2.43), and react/react-dom 19.2.0 so the SDK's optional react ^18 peer can't pull a second react and reshuffle the tree Result: one hoisted viem 2.47.6, no nested copy, so the SDK types resolve against the app's viem — the `as any` boundary cast in buildTokenSaleClient is removed. `npm install` resolves with no ERESOLVE / no --legacy-peer-deps; npm ci, next dev, type-check (both workspaces, no cast) and backend build all green. Lockfile -295 lines (the nested viem 2.43.3 subtree).
clestons
left a comment
There was a problem hiding this comment.
clestons review — #357 [REQUEST_CHANGES]
feat(tokens): gasless GToken/aPNTs purchase page + SDK 0.26.4 — refactors /sale → /tokens, driven by the SDK's TokenSaleClient (good — it deletes ~1200 lines of hand-rolled ABI/EIP-712 in favor of the verified SDK flows). The integration is mostly right, but it ships a payment page with no slippage protection.
🔴 BLOCKING — buys go out with minOut = 0 (no slippage guard) — the exact launch#20 bug, recurring
The buy calls omit minOut:
sale.buyGasless({ token, usdAmount: usd(amt), recipient: to })— nominOutsale.buySelfPay({ token, usdAmount: usd(amt), payToken })— nominOut
And the SDK defaults it to 0n (tokenSale.ts: "Minimum tokens out (slippage guard). Default 0n", const minOut = params.minOut ?? 0n). So both flows authorize "accept any amount of tokens, even near-zero" — no protection against price movement / MEV sandwich. This is precisely the issue launch#20 fixed for join.html (flagged HIGH; minOut = estimatedTokens × 0.98), and this page mirrors that join flow.
The page already computes a live quote (quoteOut), so the fix is small: pass
minOut = (quoteOut * 98n) / 100n // 2% tolerance, matching launch#20
to buyGasless and to buySelfPay for the GToken path and the aPNTs gasless path. (aPNTs self-pay can't enforce it — APNTsSaleContract.buyAPNTs has no slippage param, and the SDK throws on minOut>0 there — so omit minOut only for that one path, consistent with #147.)
Good (keep)
- SDK-driven refactor (removes hand-rolled ABI/EIP-712 — less error-prone);
@aastar/sdk ^0.24.2 → ^0.26.4(the version with the verified gasless/DVT/minOut/PaymasterV4 work). - recipient defaults to the user's AirAccount (
if (airAccount && !recipient) setRecipient(airAccount)); gasless passesrecipient, self-pay credits the paying EOA (documented).
Minor (DeepSeek, non-blocking)
ensureChainsilently returns false on a rejected network switch — surface a user-facing error/toast.- The old
/saletoken-management (add/remove/search) is removed — a product decision; just confirm no flow depends on it.
Verdict
REQUEST_CHANGES. The SDK integration is correct and the recipient default is right, but the buy page must not authorize purchases with minOut = 0 — pass a quote-derived minOut (≈ ×0.98) to buyGasless/buySelfPay for the protectable paths, exactly as launch#20 established. Without it, every buy on this page is unprotected against slippage/MEV.
Codex review: buyGasless/buySelfPay were called without minOut, so the SDK defaulted to 0n (accept any output) — a MEV/sandwich risk, the same HIGH bug launch#20 fixed in the original join page. Re-quote at buy time and pass minOut = quoted * 98/100 to both GToken and aPNTs gasless paths. (aPNTs self-pay has no on-chain minOut param yet — aastar-sdk#147.)
clestons
left a comment
There was a problem hiding this comment.
clestons re-review — #357 [REQUEST_CHANGES] — slippage added, but it breaks aPNTs self-pay
Thanks — decd5967 correctly addresses my original finding: there's now a 2% slippage floor from a FRESH re-quote (const quoted = await sale.quote(...); const minOut = (quoted * 98n) / 100n), not a stale displayed quote, and it's passed to both buy paths. That part is right.
🔴 BLOCKING — minOut is passed to buySelfPay unconditionally → aPNTs self-pay now throws
result = await sale.buySelfPay({ token, usdAmount: amount, payToken, minOut }); // line 163Per #147, buySelfPay with token === 'APNTS' and minOut > 0 throws (APNTsSaleContract.buyAPNTs has no on-chain slippage param — the SDK fails closed). Your own comment (line 155) notes this — "aPNTs self-pay has no on-chain minOut param yet — aastar-sdk#147" — but the code still passes minOut for it.
This isn't a corner case: token defaults to "APNTS" and the smart default sets mode = "SELFPAY" when the user has ETH (ethBal ≥ 0.005), so an ETH-holding user's default state is aPNTs + self-pay → clicking Buy hits the SDK throw → an error toast, purchase fails. The aPNTs gasless path is fine (BuyIntent.minOut is enforced), and GToken self-pay is fine — only aPNTs self-pay breaks.
Fix (small)
Omit minOut only for the aPNTs self-pay path:
result = await sale.buySelfPay({
token, usdAmount: amount, payToken,
...(token === 'APNTS' ? {} : { minOut }),
});(Optionally surface a small "aPNTs self-pay has no slippage guard yet — use gasless for protection" hint, since that path is genuinely unprotected per #147.)
Verdict
REQUEST_CHANGES. The slippage protection itself is now correct (fresh-re-quote 2% floor on gasless + GToken self-pay — exactly what I asked for), but passing minOut to buySelfPay for aPNTs makes the SDK throw, breaking the default aPNTs-self-pay flow. Guard minOut so it's omitted only for aPNTs self-pay, then this is good to go.
… param) Codex follow-up: passing minOut>0 to buySelfPay throws for aPNTs (buyAPNTs has no on-chain minOut — aastar-sdk#147), and aPNTs + self-pay is the default landing path (token defaults to APNTS, smart-default = SELFPAY when the wallet has ETH), so the user would hit an error on the first Buy. Only pass minOut for GToken self-pay; gasless (both tokens) and GToken self-pay keep the 2% guard.
…tion, zero-quote guard Adversarial self-review of the buy flow, beyond the Codex rounds: - Re-check the wallet is on Sepolia at buy time (user may switch chains after connect; ensureChain is a silent no-op when already correct) — avoids sending to the wrong network. - Validate the gasless recipient with viem isAddress before buying — a typo in the editable recipient field must not route funds to a bad address. - Guard quoted === 0n (USD amount below 1 token unit) — otherwise minOut is 0 and the buy yields nothing with no slippage floor. Adds invalidRecipient / amountTooSmall locale keys (en + zh, parity OK).
clestons
left a comment
There was a problem hiding this comment.
clestons re-review — #357 @ 770980e [APPROVE] — both findings fixed + hardening
Both my prior REQUEST_CHANGES findings are now resolved in code (not just comments), plus the self-review added real defensive checks.
✅ RC#1 (minOut=0 / no slippage) — fixed
A 2% floor from a FRESH re-quote: const quoted = await sale.quote(token, amount); const minOut = (quoted * 98n) / 100n — not the stale displayed quote — passed to the protectable buy paths.
✅ RC#2 (aPNTs self-pay threw on minOut>0) — fixed correctly
// gasless: minOut for both GToken + aPNTs (BuyIntent.minOut is enforced)
sale.buyGasless({ token, usdAmount: amount, recipient: to, minOut });
// self-pay: minOut ONLY for GToken (buyTokens); omitted for aPNTs (buyAPNTs has no on-chain param, #147)
sale.buySelfPay({ token, usdAmount: amount, payToken, ...(token === "GTOKEN" ? { minOut } : {}) });So the slippage matrix is now correct: gasless GToken/aPNTs ✓, GToken self-pay ✓, aPNTs self-pay (the default landing path) no longer throws — it just runs without an on-chain minOut, which is the documented #147 limitation.
✅ Bonus — self-review hardening (770980e)
- Recipient validation:
if (mode === "GASLESS" && !isAddress(to)) { toast.error(invalidRecipient); return }— a typo'd gasless recipient can no longer silently send tokens to a bad address. (Good catch beyond my review.) - Chain re-check before buy:
if (!(await ensureChain(CHAIN_SEPOLIA))) …— guards against a buy after the user switched networks post-connect (also closes DeepSeek's earlier silent-ensureChainnote).
Verdict
APPROVE. The SDK-driven refactor is correct, slippage protection is now applied to every path that can enforce it (2% fresh-re-quote floor), aPNTs self-pay is unbroken (minOut correctly omitted per #147), and the buy is gated by recipient-address + chain re-checks. Merge is the maintainer's call.
What
Refactors
/saleinto/tokens— a frictionless buy page for the two Mycelium tokens, mirroring launch.mushroom.cv/join but driven by the SDK's@aastar/sdk/tokensTokenSaleClient(no hand-rolled ABI / EIP-712).Buy flows
buyGasless: EIP-3009TransferWithAuthorization+BuyIntent→ the relayer pays gas. Default recipient = the user's AirAccount.buySelfPay: approve + buy, user pays ETH gas.Positioning / copy
Also
/sale→/tokensroute + Layout menu/paymaster: banner linking to/tokenstokensPagenamespace); i18n parity check passeslib/sdk/client: bind account onconnectWallet, addensureChain+buildTokenSaleClientDeps:
@aastar/sdk^0.24.2→^0.26.40x1f0D…→0x957852…). It's sourced from canonical, so the paymaster preset auto-updates.as anycast is removed.The lockfile shrinks ~1200 lines: the nested viem 2.43.3 subtree under
@aastar/sdkis correctly dropped now that viem is a peer.npm civerified (next intact); type-check + build green on both workspaces.Deferred (tracked, not in this PR)
buyTokensFor([合约] sale 加 buyTokensFor/buyAPNTsFor(to,…) — 自付购买指定收款人(SDK#145 缺口2) MushroomDAO/launch#21) + SDKrecipientpass-through (aastar-sdk#145)Verification
npm ci