Skip to content

feat: normalizeTokenAddress, dualChainConnect, useQuery migration#51

Merged
akbarsaputrait merged 17 commits into
masterfrom
feat/normalize-dualchain-query-migration
Jul 14, 2026
Merged

feat: normalizeTokenAddress, dualChainConnect, useQuery migration#51
akbarsaputrait merged 17 commits into
masterfrom
feat/normalize-dualchain-query-migration

Conversation

@akbarsaputrait

@akbarsaputrait akbarsaputrait commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

Backport and extension of improvements from PR #47 onto master, plus new mobile UX and iOS deeplink improvements.


1. Chain-aware token address normalization (normalizeTokenAddress)

EVM token addresses are case-insensitive (EIP-55 checksummed). Solana (base58) and Stellar (StrKey) are case-sensitive — lowercasing corrupts them. All previous code used .toLowerCase() everywhere, breaking lookups for native Solana/Stellar tokens.

Changes:

  • pay-common/src/token.ts: new normalizeTokenAddress(chainId, address) — EVM lowercased, Solana/Stellar pass-through
  • tokensByChainAddr map and getKnownToken use normalizeTokenAddress
  • getChainName wraps in try/catch, returns "Chain {id}" instead of throwing
  • Applied across all token comparison call sites: validatePayoutToken, useTokenOptions, useWalletPaymentOptions, useDepositAddressOptions, useSolanaPaymentOptions, useStellarPaymentOptions, Confirmation page
  • moduleResolution: node → bundler in connectkit tsconfig (resolves ox exports map, removes false build warning)
  • @rozoai/intent-common dep switched to workspace:*

2. useQuery migration for payment option hooks

All four hooks replace a custom refreshUtils state machine with @tanstack/react-query useQuery. refreshUtils.ts deleted.

Hooks migrated: useWalletPaymentOptions, useSolanaPaymentOptions, useStellarPaymentOptions, useDepositAddressOptions

Shared config: staleTime: 30_000, retry: 1, refetchOnWindowFocus: false

Key improvements:

  • useDepositAddressOptions: enabled guard requires usdRequired > 0 (prevents premature query before order loads); appId param removed; error path returns fallbackOptions so UI never goes blank
  • useTokenOptions: refreshPromises typed Promise<unknown>[] to match refetch return type

3. Dual-chain wallet connect (dualChainConnect)

Phantom mobile injects both EVM and Solana simultaneously. Old code auto-routed to EVM SELECT_TOKEN the moment isEthConnected fired — no path to Solana tokens.

Changes:

  • PayContext / RozoPayProvider: new dualChainConnect: boolean state
  • ConnectorList: mobile dual-chain path calls connect() directly (no intermediate ConnectWithInjector page) + setDualChainConnect(true) + solanaWallets.select() simultaneously
  • RozoPayModal: when isEthConnected fires with dualChainConnect=true, routes to SELECT_METHOD (shows both wallet tiles) instead of SELECT_TOKEN

Result: Tap Phantom → both chains connect silently → land on SELECT_METHOD with EVM + Solana tiles.


4. trimTokenAmount

Strips trailing zeros from decimal token amount strings ("0.01600" → "0.016"). Applied in:

  • PaymentBreakdown: fee + total display
  • WaitingDepositAddress: fee row + "Send Exactly" (replaces formatAmountWithTokenSymbol which rounded to 2dp — wrong for native tokens like 0.016885 SOL)

5. Mobile wallet chooser UX overhaul

Problem: "Pay with wallet" on mobile required 3 steps: tile → Connectors page (pulse skeleton waiting for hydration) → tap "Other" → Choose Wallet page → tap wallet → deeplink.

Changes:

  • SelectMethod: "Pay with wallet" routes directly to ROUTES.MOBILECONNECTORS on mobile (skips Connectors page entirely)
  • SelectMethod: eagerly calls hydrateOrder() on modal open (gated on modalOpen && isMobile) so deeplink wallets have a payId ready before user taps
  • MobileConnectors: rewritten — shows deeplink wallets alphabetically; renders immediately; pulse animation while order hydrates; taps silently ignored until orderReady
  • MobileConnectors: dedup injected vs deeplink wallets by cross-matching name/shortName so Phantom only appears once
  • MobileConnectors/styles: img elements get width: 100%; height: 100%; object-fit: cover (same as svg) so injected wallet icons (Rabby etc) render at correct 60x60
  • SelectMethod: "Pay to address" tile now shows while depositAddressOptions.loading (disabled + "Loading..." subtitle) instead of being hidden until options arrive
  • SelectMethod: "Pay to address" stays in position (above Stellar) while loading — excluded from disabled-to-bottom sort

6. iOS deeplink — skip WAITING_WALLET intermediate page

Problem: On iOS Safari/WebKit, window.open() from JavaScript is blocked unless triggered by a direct user gesture. The SDK used to route to WAITING_WALLET page with a "Continue in Phantom" button — one extra tap.

Fix: MobileConnectors now opens the wallet deeplink by programmatically creating and clicking an <a> tag inside the direct user gesture context (the wallet tap). Safari/WebKit allows <a> tag clicks from user gestures even when window.open is blocked. Modal closes immediately — user goes straight to the wallet app.

Result (iOS): Tap Phantom → deeplink opens directly, no intermediate page.
Result (Android): Same — window.open works, modal closes, user goes to wallet app.


Tested

  • tsc --noEmit: 0 errors (connectkit + pay-common)
  • rollup build: clean, no warnings
  • pnpm lint: passes
  • Manual: dual-chain Phantom mobile connect, mobile wallet chooser flow, deposit address loading state, iOS deeplink direct open

- pay-common: chain-aware normalizeTokenAddress (EVM=lowercase,
  Solana/Stellar=pass-through); safe getChainName fallback
- pay-common: tokensByChainAddr + getKnownToken use normalizeTokenAddress
- connectkit: workspace:* dep + moduleResolution bundler (fixes ox types)
- connectkit: migrate useWalletPaymentOptions, useSolanaPaymentOptions,
  useStellarPaymentOptions, useDepositAddressOptions to @tanstack/react-query;
  remove refreshUtils.ts
- connectkit: useDepositAddressOptions enabled guard usdRequired > 0,
  appId param removed, fallback on error
- connectkit: dualChainConnect state in PayContext/RozoPayProvider;
  ConnectorList sets flag on mobile dual-chain tap; RozoPayModal guards
  route to SELECT_METHOD instead of EVM SELECT_TOKEN
- connectkit: trimTokenAmount applied in PaymentBreakdown +
  WaitingDepositAddress for native token display
- connectkit: normalizeTokenAddress applied across validatePayoutToken,
  useTokenOptions, useDepositAddressOptions, useWalletPaymentOptions,
  Confirmation page token comparisons
@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
intent-example Ready Ready Preview, Comment Jul 14, 2026 4:40am

Request Review

@github-actions

Copy link
Copy Markdown

AI review: 0 P0 / 4 P1 / 3 P2 — no blocking issues found

Labeled ai-review-passed. This is advisory; a human still merges.

P0 (blocking)

None.

P1 (should fix)

  • examples/nextjs-app/package.json: @rozoai/intent-common and @rozoai/intent-pay are pinned to beta versions (0.1.23-beta.1, 0.1.36-beta.1). Shipping an example app (and by extension, any consumer copying its lockfile/config) against beta releases of the payment SDK risks pulling in unstable/untested payment logic. Confirm this is intentional and scoped to the example only, not propagated to any published package.
  • package.json: dev:example:16 script was removed but dev still references it ("dev": "pnpm run dev:common & pnpm run dev:pay & pnpm run dev:example:16", line ~19). This will break pnpm dev since the referenced script no longer exists (it pointed at examples/nextjs-16, which appears removed/renamed). Verify examples/nextjs-16 still exists or update dev to reference dev:example / the new start:example.
  • package.json: new build target now also runs build:example (cd examples/nextjs-app && pnpm run build). If the example app's build fails (e.g., due to the beta SDK versions above), it will now break the top-level build script and CI, whereas previously only the two core packages were built. Confirm example build is stable enough to gate the monorepo build, or make it a separate/non-blocking script.
  • bundle-analysis.html: This is a large generated build artifact (rollup-plugin-visualizer output) checked into the diff with regenerated hashes/uids. Committing generated bundle-analysis reports as part of source control bloats the diff and repo history and offers no review value — should be gitignored rather than committed on every build change.

P2 (nice to have)

  • examples/nextjs-app/.gitignore: Adding .wallet-context/ is fine, but the trailing blank line was removed — minor formatting churn with no functional effect; could keep the newline for consistency with the rest of the file.
  • package.json: New start:example script was added but no corresponding dev:example:16 replacement or documentation update — consider adding a short comment or README note explaining when to use build:example/start:example vs dev:example.
  • Consider excluding bundle-analysis.html from version control entirely (e.g., add to .gitignore) since it's a rollup/vite visualizer artifact regenerated on every build with non-deterministic uids, causing noisy diffs like this one.

ai-pr-review · claude-sonnet-5

shawnmuggle
shawnmuggle previously approved these changes Jul 13, 2026

@shawnmuggle shawnmuggle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review found no P0 blockers for a trusted Rozo contributor.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8dfbf278bc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/connectkit/src/utils/format.ts
Comment thread packages/connectkit/src/components/RozoPayModal/index.tsx Outdated
Connect EVM directly via connect() instead of routing to ROUTES.CONNECT,
removing the 'Connect Phantom' intermediate screen. RozoPayModal effect 2
now handles dualChainConnect=true at any route (not just CONNECT page),
clearing the flag and routing to SELECT_METHOD to show both wallet tiles.
@github-actions

Copy link
Copy Markdown

AI review: 0 P0 / 4 P1 / 3 P2 — no blocking issues found

Labeled ai-review-passed. This is advisory; a human still merges.

P0 (blocking)

None.

P1 (should fix)

  • package.json:24dev:example:16 script was deleted, but dev (line 21) still calls pnpm run dev:example:16. This will break pnpm dev at the workspace root. Confirm whether examples/nextjs-16 is intentionally being retired; if so, update the dev script accordingly, and remove any other references to it (CI configs, docs).
  • package.json — Root build script now runs build:example (examples/nextjs-app), which pulls in beta dependency versions (@rozoai/intent-common@0.1.23-beta.1, @rozoai/intent-pay@0.1.36-beta.1, see examples/nextjs-app/package.json:43-44). Shipping a beta SDK dependency in a build path that presumably feeds CI/release pipelines risks pulling untested code into the build; pin exact beta versions (already done) but ensure CI lockfile is regenerated/committed and beta channel is intentional for this merge, not leftover from local testing.
  • examples/nextjs-app/package.json — Bumping to beta pre-release versions of core payment SDKs (intent-common, intent-pay) is a meaningful dependency change for a payment example app with zero explanation/changelog in the diff. Given these libraries implement payment flows, verify beta compatibility and pin via lockfile; there's no test coverage evidence for the new beta behavior in this diff.
  • packages/connectkit/bundle-analysis.html — This is a large generated build artifact (bundle stats/treemap) checked into source control and diffed wholesale due to non-deterministic module UIDs (d2d3018c-*000eda69-*). This bloats the diff/history for no functional value; this file should be gitignored and generated in CI, not committed.

P2 (nice to have)

  • examples/nextjs-app/.gitignore:54 — Added .wallet-context/ entry with no accompanying code that creates such a directory in this diff; confirm this matches actual generated artifact naming (compare with .wallet-cache/ above) to avoid a stale/incorrect ignore rule.
  • package.json:26 — New start:example script added but not referenced by any other script (e.g., release, CI). Consider documenting its intended usage or wiring it into relevant workflows.
  • package.json — Renaming/removing dev:example:16 without a clear migration note (e.g., in CHANGELOG or PR description) makes it harder for contributors to know the Next.js 16 example was dropped/renamed.

ai-pr-review · claude-sonnet-5

shawnmuggle
shawnmuggle previously approved these changes Jul 13, 2026

@shawnmuggle shawnmuggle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review found no P0 blockers for a trusted Rozo contributor.

…s loading state

- SelectMethod: route directly to MOBILECONNECTORS on mobile (skip
  Connectors page); eagerly call hydrateOrder() on modal open so
  deeplink wallets have a payId before user taps
- MobileConnectors: show injected wallets (connect directly) + deeplink
  wallets in one list immediately; pulse animation while order hydrates;
  taps silently ignored until orderReady
- SelectMethod: show 'Pay to address' tile while depositAddressOptions
  loading (disabled + 'Loading...' subtitle); previously hidden until
  options arrived
…order

- MobileConnectors: fix name/shortName cross-match so injected Phantom
  hides deeplink Phantom duplicate
- MobileConnectors/styles: apply width/height/object-fit to img same as
  svg so injected wallet icons (Rabby etc) render at correct 60x60
- SelectMethod: exclude depositAddress from disabled-to-bottom sort so
  loading 'Pay to address' tile stays above Stellar
…ediate page

MobileConnectors handleDeeplinkWallet: on iOS (and all platforms),
open the wallet deeplink by programmatically clicking an <a> tag
inside the direct user gesture context instead of routing to
WAITING_WALLET page. Safari/WebKit allows <a> clicks from user
gestures even when window.open is blocked. Closes modal immediately
so user goes straight to wallet app.

Also fix appId resolution from order.metadata to use proper
type assertion for metadata Record<string, any> access.

chore: version bumps, bundle analysis, lockfile
@akbarsaputrait

Copy link
Copy Markdown
Member Author

/ai-review

window.doNotTrack access threw ReferenceError under Next.js SSR,
returning HTTP 500 for the entire checkout page on invoice.rozo.ai
when telemetry was enabled. Extract isDNTEnabled to a util and gate
on typeof window === 'undefined' before touching window.doNotTrack.

Adds a node:test regression check that fails on the pre-fix shape.

Fixes invoice.rozo.ai SSR incident 2026-07-13.
Fires once per empty-render episode when SelectToken renders its
'No tokens available' state with a connected wallet. Re-arms after
refresh or reconnect so a subsequent empty state re-captures.

Properties: token_mode, connected_chains, preferred_token_symbols,
preferred_token_count — enough to segment funnel drop-off by chain
and by whether preferredTokens narrowed the eligible set.
- CHANGELOG: 0.1.36-beta.5 entry for both changes.
- ANALYTICS.md: add payment_no_tokens_available to event catalog.
- PROVIDER_SETUP.md: document the SSR isDNTEnabled fix in Common
  Mistakes, note that telemetry={false} workaround is no longer needed
  on 0.1.36+.
- 06-wallet-stellar.md: expand section 3 to explain the two safety gates
  (NEXT_PUBLIC_E2E build flag + window.__E2E_STELLAR_SECRET__ runtime
  injection), match the real providers.tsx wiring, and note that the
  provider deletes the secret from window after reading. Add Notes entry
  pointing at the Playwright webServer command.
- .env.e2e.example: clarify that Playwright starts the dev server with
  NEXT_PUBLIC_E2E=1 automatically \u2014 no manual env var to set.
@shawnmuggle

Copy link
Copy Markdown
Member

Review summary

Reviewed the full diff (connectkit + pay-common) with two independent passes plus manual verification of the payment-critical paths. No P0 / blocking issues. The token/chain data changes are safe: zero token address/decimals/symbol value changes, zero tokens added or removed, zero exported API removed — the ~3k-line churn in token.ts/chain.ts is diff-generation noise, the only real edits are normalizeTokenAddress, the map/lookup switch, and the getChainName try/catch. Findings below are all non-blocking.

Please confirm (behavior change, no changelog)

  • Payment options no longer load when using the default appId. useWalletPaymentOptions.ts, useSolanaPaymentOptions.ts, useStellarPaymentOptions.ts — the new useQuery enabled gate now requires appId != null && appId !== DEFAULT_ROZO_APP_ID. The old hooks fetched with stableAppId = appId ?? DEFAULT_ROZO_APP_ID and gated only on != null, i.e. they fetched even under the default appId. Any integration that relies on the default "rozoIntentPay" appId will now show a connected wallet with zero payable tokens. This is almost certainly intentional (querying balances under the shared default appId is meaningless), and real integrations pass their own appId — just flagging it because it's an undocumented tightening.

P2 (non-blocking)

  • trimTokenAmount emits scientific notation for sub-1e-6 values. utils/format.tsString(parseFloat(amount)) turns "0.0000001" into "1e-7". Used in "Send Exactly" / fee text (WaitingDepositAddress) and fee+total (PaymentBreakdown). Note this change also fixes a worse bug (the old formatAmountWithTokenSymbol rounded native-token amounts to 2 dp, e.g. 0.016885 SOL → 0.02), and the "Send Exactly" clipboard copy uses the raw un-trimmed depAddr.amount, so the copyable amount is always correct — only the displayed text can go scientific, and only for amounts below ~1e-6. Suggest a fixed-notation formatter (toLocaleString/toFixed-based) instead of String(parseFloat()).
  • Eager hydrateOrder() has no in-flight dedup in the FSM effect. SelectMethod/index.tsx and MobileConnectors/index.tsx both eagerly hydrate; runHydratePayParamsEffects (payment/paymentEffects.ts) calls the createPayment POST with no request-id guard (unlike set_pay_id/set_pay_params). Per-component hasHydratedRef doesn't protect across a page transition while the POST is in flight, so a fast nav during hydration could create a duplicate payment record. Narrow window (the two pages are on mutually-exclusive routes) and pre-existing via Connectors, but this PR widens it to 3 eager-hydrate pages — worth an in-flight guard keyed on order.id.
  • useDepositAddressOptions drops the mode API param and skips fetching at usdRequired === 0. hooks/useDepositAddressOptions.ts — old hook passed mode to getDepositAddressOptions.query and fetched when usdRequired != null && mode != null (incl. 0); new hook omits mode and gates on usdRequired > 0. For a normal order usdRequired > 0 so this is inert, but dropping mode is a server-contract change — confirm the backend ignores it.
  • normalizeTokenAddress misclassifies unregistered EVM chains as case-sensitive (latent). pay-common/token.ts classifies via getChainById, which only searches supportedChains. celo (42220), linea (59144), mantle (5000), worldchain (480) are defined with tokens but not in that list → they fall through to case-preserving instead of lowercased, contradicting the helper's own doc. Latent today (none are in supportedTokens), but the helper is exported and used in 5 comparison sites, so any new EVM chain added to token.ts but not supportedChains will silently compare case-sensitively. Suggest classifying by address shape (/^0x[0-9a-fA-F]{40}$/ → lowercase) or registering all EVM chains.
  • getChainName returns "Chain {id}" for defined-but-unregistered chains. Same registry gap — celo/linea/mantle/worldchain/stellar (10001) render as "Chain 42220" instead of their real name. The try/catch is still a net improvement (pre-PR this path threw and crashed render); the label is just wrong-but-graceful.

Verified clean (no bug)

  • useQuery migration: all 4 hooks preserve the old public shape (options/isLoading/refreshOptions); refreshOptions: refetch is a safe swap (callers only await completion and read .options.length off the returned array); query keys are stable (JSON.stringify-memoized), no infinite refetch or cross-order stale cache; no blank-flicker regression.
  • dualChainConnect: the two effects in RozoPayModal are idempotent and always clear the flag on isEthConnected (flag can't get stuck if Solana select() fails — degrades to EVM-only).
  • iOS deeplink flow: payment detection does not depend on the removed WAITING_WALLET route — the global watcher in RozoPayProvider routes to CONFIRMATION on payment_completed/payment_bounced regardless of route. Deeplink payId/appId are equivalent to the old openInWalletBrowser. (Minor: setOpen(false) is commented out, so the modal stays on MOBILECONNECTORS when the user returns — UX only, confirm intended.)
  • isDNTEnabled SSR guard is correct.

shawnmuggle
shawnmuggle previously approved these changes Jul 14, 2026

@shawnmuggle shawnmuggle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved — no P0/blocking issues. Findings are non-blocking (P2) plus one behavior change to confirm (default-appId payment options gate); details in the review summary comment above.

Previous gate skipped useQuery when appId was absent or equal to
DEFAULT_ROZO_APP_ID ("rozoIntentPay"), so any integration that didn't
pass an appId (or used the default) rendered a connected wallet with
zero payable tokens and fired payment_no_tokens_available immediately.

Restore the pre-migration behavior: fall back to DEFAULT_ROZO_APP_ID
(same fallback paymentEffects/createPaymentPayload/usePaymentState
already use downstream) and drop the DEFAULT-app-id short-circuit
from the enabled clause. Balance queries now run consistently across
wallet/solana/stellar hooks regardless of appId config.

@shawnmuggle shawnmuggle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-approved after incremental review of d9b6566...fbf7c91.

The default-appId gate I flagged is correctly fixed across all three hooks (useWalletPaymentOptions, useSolanaPaymentOptions, useStellarPaymentOptions): the appId !== DEFAULT_ROZO_APP_ID condition is removed from enabled, stableAppId restores the ?? DEFAULT_ROZO_APP_ID fallback, and the non-null assertion on appId in queryFn is dropped — queryKey and request param stay in sync, no stale-cache risk. The new SelectToken PAYMENT_NO_TOKENS_AVAILABLE analytics is self-contained (fires once per empty-render episode, re-arms on state flip), no payment-path side effects. Versions finalized beta→release (intent-pay 0.1.36 / intent-common 0.1.23), example app no longer depends on beta SDKs. No new blocking issues; remaining items are the previously-noted non-blocking P2s.

@akbarsaputrait
akbarsaputrait merged commit c29485e into master Jul 14, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants