Skip to content

feat(tokens): gasless GToken/aPNTs purchase page + SDK 0.26.4#357

Merged
jhfnetboy merged 6 commits into
masterfrom
feat/tokens-sale-page
Jun 23, 2026
Merged

feat(tokens): gasless GToken/aPNTs purchase page + SDK 0.26.4#357
jhfnetboy merged 6 commits into
masterfrom
feat/tokens-sale-page

Conversation

@jhfnetboy

Copy link
Copy Markdown
Member

What

Refactors /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).

Buy flows

  • Gasless (USDC)buyGasless: EIP-3009 TransferWithAuthorization + BuyIntent → the relayer pays gas. Default recipient = the user's AirAccount.
  • Self-pay (USDC / USDT)buySelfPay: approve + buy, user pays ETH gas.
  • Smart default (low ETH → gasless), live quote, balances, MetaMask connect + Sepolia switch. WETH/WBTC shown as "soon" (needs oracle).

Positioning / copy

  • GToken = Digital Public Goods ticket (governance & participation)
  • aPNTs = Utility Computing Unit that powers gasless service (pays gas via SuperPaymaster)

Also

  • /sale/tokens route + Layout menu
  • /paymaster: banner linking to /tokens
  • tokens page fully bilingual (en/zh tokensPage namespace); i18n parity check passes
  • 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…0x957852…). It's sourced from canonical, so the paymaster preset auto-updates.
  • 0.26.4 made viem a peerDependency (aastar-sdk#157) → the SDK's types resolve against the app's own viem; the prior as any cast is removed.

The 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 on both workspaces.

Deferred (tracked, not in this PR)

Verification

  • ✅ type-check (both), backend build, lint, i18n parity, npm ci
  • ⏳ Browser: connect MetaMask (Sepolia, test USDC) → gasless buy aPNTs into AirAccount

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).
@jhfnetboy jhfnetboy requested a review from fanhousanbu as a code owner June 23, 2026 02:20
@chatgpt-codex-connector

Copy link
Copy Markdown

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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }) — no minOut
  • sale.buySelfPay({ token, usdAmount: usd(amt), payToken }) — no minOut

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 passes recipient, self-pay credits the paying EOA (documented).

Minor (DeepSeek, non-blocking)

  • ensureChain silently returns false on a rejected network switch — surface a user-facing error/toast.
  • The old /sale token-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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 163

Per #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 clestons left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-ensureChain note).

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.

@jhfnetboy jhfnetboy merged commit c412cbf into master Jun 23, 2026
11 of 14 checks passed
@jhfnetboy jhfnetboy deleted the feat/tokens-sale-page branch June 23, 2026 03:00
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants