Feat - Native Tokens (ETH/SOL/XLM) Payment support - #47
Conversation
…end-to-end
Native sources were treated as 1:1 USD-pegged, so amounts were wrong across
the transfer, fee, create-payment, and display paths. Separate the three
distinct amounts (USD value / source-token amount / destination payout) and
thread them correctly.
- payWithToken/Solana/Stellar transfers: use required.amount (source-token
units) instead of required.usd
- add decimal-tolerant helpers tokenBaseAmountToDecimalString /
tokenAmountToBaseUnits (fixes "Cannot convert 0.0129… to a BigInt")
- createPayment payload: send source.amount via new preferredAmountUnits,
keep destination payout in toUnits (no longer conflated)
- getFee amount is now type-aware: source-token amount for exactIn,
USD/destination for exactOut
- formatPaymentResponseToHydratedOrder: derive usdValue/destFinalCallTokenAmount
from destination, not source.amount (fixes cancel → $0.02 + corrupted
usdRequired); surface source.amount via metadata for deposit flow
- default preferredSymbol now includes natives so they aren't filtered out
- keep Solana lamports/transferAmount as bigint (no Number() precision loss)
- tsconfig moduleResolution node → bundler (drops false ox@0.14.30 type error)
- native tokens render chain icon instead of logoURI on SelectToken + pay pages
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…d @rozoai/intent-pay v0.1.35-beta.1
There was a problem hiding this comment.
💡 Codex Review
intent-pay/packages/connectkit/src/hooks/usePaymentState.ts
Lines 792 to 795 in fc97bc8
When the user selects a native EVM token on a chain different from the wallet's current chain, this branch sends without either switchChainAsync({ chainId: required.token.chainId }) or a chainId on sendTransactionAsync. Unlike the ERC-20 branch below, the wallet can therefore submit the value transfer on the currently selected chain while the backend expects the payment on required.token.chainId, which can make native ETH/BNB/POL payments fail or land on the wrong chain.
ℹ️ 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".
…ment transaction logic - Removed redundant imports for token formatting utilities. - Consolidated token chain ID checks and data suffix handling for improved clarity and efficiency. - Enhanced deep link generation for EVM payments by simplifying amount unit parsing.
AI review: 0 P0 / 6 P1 / 4 P2 — no blocking issues foundLabeled P0 (blocking)None. P1 (should fix)
P2 (nice to have)
ai-pr-review · claude-sonnet-5 |
|
ai-pr-review swept this head ( |
…d @rozoai/intent-pay v0.1.35-beta.2
AI review: 3 P0 (blocking) / 6 P1 / 4 P2P0 findings must be addressed before merge. This is advisory — no auto-approve, no auto-block; a human decides. P0 (blocking)
P1 (should fix)
P2 (nice to have)
ai-pr-review · claude-sonnet-5 |
AI review: 3 P0 (blocking) / 6 P1 / 5 P2P0 findings must be addressed before merge. This is advisory — no auto-approve, no auto-block; a human decides. P0 (blocking)
P1 (should fix)
P2 (nice to have)
ai-pr-review · claude-sonnet-5 |
|
ai-pr-review swept this head ( |
- Add getTokenPrices API (pay-common) for live USD conversion
- WaitingDepositAddress: convert USD → source-token units via live price
for native sources (SOL/ETH/XLM); getFee uses actual feeType (no
ExactOut override)
- buildCreatePaymentPayload: destination stays at toUnits — fee is on
source side, not destination (was: ExactOut branch subtracted fee)
- usePaymentState.payWithDepositAddress: source amount from fees response
(fee-inclusive) for both native and stablecoin; was bare toUnits
- fetchApi: surface BE structured error message, not raw JSON envelope
- PayWithToken/{Solana,Stellar}: scope native ExactOut override to
fee-quote only (createPayment keeps payParams.feeType)
- WaitingDepositAddress QR: nativeAsChainIcon for native tokens (single
chain badge, no redundant token+chain pair)
- Bump @rozoai/intent-common → 0.1.22-beta.3, @rozoai/intent-pay → 0.1.35-beta.3
- Add evm-native / solana-native / stellar-native e2e specs - Wire them into playwright.config.ts (sequential deps on existing merchant suite; reroute deposit-stellar-to-evm dep) - Extract APP_ID to app/const.ts (shared by Bridge/Checkout/DepositMode) - snippets.ts: import reorder + prettier semi-colons
AI review: 2 P0 (blocking) / 8 P1 / 5 P2P0 findings must be addressed before merge. This is advisory — no auto-approve, no auto-block; a human decides. P0 (blocking)
P1 (should fix)
P2 (nice to have)
ai-pr-review · claude-sonnet-5 |
- add AGENTS.md - pay-common: safe getChainName fallback, chain-aware normalizeTokenAddress - connectkit: dualChainConnect state + fallback QueryClientProvider - connectkit: migrate payment option hooks to @tanstack/react-query - connectkit: add generateStellarDeepLink helper - connectkit: remove refreshUtils.ts
- point @rozoai/intent-common/pay to workspace:* in package.json files - update pnpm-lock.yaml for workspace dependency resolution - add packages/connectkit/.oxfmtrc.json
…ization for multi-chain payments
AI review: 3 P0 (blocking) / 9 P1 / 4 P2P0 findings must be addressed before merge. This is advisory — no auto-approve, no auto-block; a human decides. P0 (blocking)
P1 (should fix)
P2 (nice to have)
ai-pr-review · claude-sonnet-5 |
- Add missing native specs: checkout/solana-native, deposit/evm-native, deposit/solana-native, merchant/polygon-native - Add pay-to-address payment-flows mode (merchant-evm, merchant-evm-native, merchant-solana fixme) with correct DepositAddressPaymentOptions testIds - Replace 30+ test:e2e:* npm scripts with single e2e/run.cjs runner (pnpm test:e2e -- <project>, --list, --mocked, --ui) - Update playwright.config.ts with all new projects in dependency chain - Update README with full E2E usage guide and maintenance contract fix(sdk): usdRequired=0 shows wrong deposit-address chain options - useDepositAddressOptions: guard enabled with usdRequired > 0 to prevent querying with zero value before order loads - startMerchantDepositAddressCheckout: wait for order sentinel before returning so usdRequired is populated when pay-to-address is clicked - getDepositAddressInfo: wait for data-value attributes to be non-empty before reading (async API population race) fix(sdk): native source amount corrupts usdValue in formatPaymentResponseToHydratedOrder - bridge-utils: detect native source token via address sentinel set (ethAddress/zeroAddress/SOL system program/XLM) instead of fiatISO; use destination.amount for native sources, source.amount for stablecoins refactor(pay-common): expose isNativeToken from pay-common, remove duplicate - token.ts: export NATIVE_TOKEN_ADDRESSES + isNativeToken(string) with zeroAddress added to sentinel set - bridge-utils: import isNativeToken from same package, drop inline set - connectkit/utils/token.ts: re-export isNativeToken from @rozoai/intent-common, drop local implementation - Update all isNativeToken callers to pass address string instead of Token object
- Type metadata.sourceAmountUnits in zRozoPayOrderMetadata Zod schema
- Remove as any casts: HydrateWalletOption type, store param narrowed to { dispatch }
- Native deposit source throws on missing sourceAmountUnits instead of wrong usdValue fallback
- Fix rozoPaymentResponse undefined fallback in PayWithSolanaToken hydrate path
- Price NaN guard + stale flag warning in WaitingDepositAddress
- Fix tokenBaseAmountToDecimalString param: BigIntStr -> string
- Fix bridge-utils prefferedToken typo
- Fix dualChainConnect flag not cleared in second useEffect — fixes Phantom mobile Solana-only pay-with-another-wallet doing nothing
- Atomic setRoute: merge route+routeMeta into single state in RozoPayProvider — fixes two-render race on Phantom mobile payId mode causing isExplicitBackNavigation to miss and re-navigate user to SELECT_TOKEN
- Pay to Address loading: disabled option + spinner replaces chain icons (Option.loading prop)
- ConnectorStellar: add Try Again + Cancel buttons on connection error
- ConnectorSolana: add error state via adapter error event + Try Again / Cancel buttons
- Add CHANGELOG.md to connectkit
- RozoPayModal: auto-trigger injected connector connect() when inside Phantom in-app browser with Solana-only auto-connected (deeplink case) - SelectMethod: extract disconnectAll() using Promise.allSettled with guards (isSolanaConnected/isStellarConnected) to prevent TypeError when disconnect is undefined on uninitialized adapter
shawnmuggle
left a comment
There was a problem hiding this comment.
Reviewed the money-critical paths in full (not just the diff). The three-amount decoupling (USD value / source amount / destination payout) is done correctly in almost every path — but the Solana native SOL transfer has a fund-correctness bug that should gate merge.
🔴 P1 (borderline P0) — native SOL transfer uses a non-shape-safe parseUnits on the wrong value
packages/connectkit/src/hooks/usePaymentState.ts:1027-1039
The code computes the lamports the safe way, then throws it away:
// L1027 — shape-safe, decimal-tolerant, matches every other transfer path
const lamports = tokenAmountToBaseUnits(walletPaymentOption.required.amount, solanaSOL.decimals);
log("[PAY SOLANA] Transfer amount (lamports):", lamports.toString());
instructions.push(SystemProgram.transfer({
fromPubkey: fromKey,
toPubkey: toKey,
lamports: parseUnits(rozoPayment.sourceAmount, solanaSOL.decimals), // L1037 — different value, non-shape-safe
}));The executed line uses parseUnits(rozoPayment.sourceAmount) — a different value source and a non-shape-safe conversion. rozoPayment.sourceAmount has two construction paths with inconsistent shape handling:
PayWithSolanaToken/index.tsx:185→tokenBaseAmountToDecimalString(required.amount, …)— detects integer-vs-decimal shape (its own comment: "Some endpoints return required.amount as an already-human-readable decimal string instead of integer base units, so detect the shape first"). ✅PayWithSolanaToken/index.tsx:430-433→rozoPaymentResponse?.source.amount ?? hydratedOrder.metadata?.sourceAmountUnits ?? undefined— raw, no shape detection. This is the hydrate-existing-order path. ❌
Failure scenario: on the raw path, if the backend returns SOL source.amount as integer lamports (the field is documented as BigIntStr, and this PR's own helper docstring notes some endpoints do return it that way), e.g. "16885000" for 0.016885 SOL, then parseUnits("16885000", 9) = 16885000 × 10⁹ lamports → the user attempts to send ~16.8M SOL instead of 0.0169 SOL (catastrophic overpay, or the tx fails). Every other transfer path (EVM native L673, SPL L1094, Stellar L1220) routes through the shape-safe helper — the native SOL path is the sole unguarded one.
Fix: use the already-computed lamports bigint from L1027 in the instruction, or route sourceAmount through tokenAmountToBaseUnits instead of bare parseUnits.
Also note: formatPaymentResponseToHydratedOrder — the single most money-critical function in this PR, which feeds metadata.sourceAmountUnits into exactly this path — has no unit test for its native-vs-stablecoin branching. Please add one covering an integer-base-units SOL source.amount.
🟠 P1 — native-source detection hinges on a non-canonical SOL sentinel
packages/pay-common/src/token.ts:700 + 1281. solanaSOL.token is "11111111111111111111111111111112" (System Program …111 with the last char bumped to 2 — an internal sentinel, not the real system program, not WSOL So111…112), and NATIVE_TOKEN_ADDRESSES only lists that sentinel. If the backend ever returns native SOL source in any other form (…111 / WSOL / null), isNativeToken() returns false → formatPaymentResponseToHydratedOrder takes the stablecoin branch and treats the native source.amount as the USD/destination payout → usdValue/destFinalCallTokenAmount mis-valued. Mitigated because the SDK controls the address it sends, but fragile.
🟡 P2
createPaymentPayload.ts:203—toUnits: String(safeToUnits)can emit"1e-7"or FP noise like"0.30000000000000004"(regression vs the old.toFixed(2));parseUnitsmay reject sci-notation. Low real-world impact (destination is a stablecoin), but worth reverting to a fixed-precision string.useWalletPaymentOptions.ts:82(+ Solana/Stellar variants) —enablednow requiresappId !== DEFAULT_ROZO_APP_ID. The old code deliberately fell back toDEFAULT_ROZO_APP_IDso balances show for every integration. Please confirm this doesn't silently disable wallet-balance options for default-appId / payId consumers.
⚠️ Merge coordination — overlaps 9 files with #53
This PR and #53 both target master and both rewrite RozoPayModal, SelectMethod, RozoPayProvider, PayContext, ConnectorSolana/Stellar, ConnectorList, OptionsList, rozoPay.ts. #53 is effectively an iteration of the same mobile-UX work here — its force-EVM-connect matches by wallet name (this PR hardcodes isPhantom() + first injected connector) and it adds a userDisconnected guard this PR lacks. They can't merge independently; please decide an order and rebase/drop the duplicate.
Verified correct (no action needed)
EVM payWithToken (shape-safe base units, backend-sourced recipient), Stellar transfer, formatPaymentResponseToHydratedOrder native-vs-stablecoin branching, the native fallback guard (throws on missing sourceAmountUnits, no bypass), fee amount type-awareness, zRozoPayOrderMetadata .nullish() fields, lamports bigint precision (no Number() truncation), recipient-address trust chain (always backend-derived), atomic setRoute.
Requesting changes on the P1 SOL transfer — the rest can follow up.
…ayments # Conflicts: # examples/nextjs-app/package.json # packages/connectkit/bundle-analysis.html # packages/connectkit/package.json # packages/connectkit/src/components/Pages/SelectMethod/index.tsx # packages/connectkit/src/components/RozoPayModal/index.tsx # packages/pay-common/package.json # pnpm-lock.yaml
|
P1 Fixed ✅ Note: cc @shawnmuggle |
Summary
Native-token payments (SOL, ETH, BNB, POL, XLM) as a payment source were broken across the SDK because the code assumed the source token was 1:1 with USD. This collapsed three distinct amounts into one:
1.3required.usd0.016885 SOLrequired.amount1.3 USDCdestFinalCallTokenAmountFor USDC/USDT the three coincide, so the bug only surfaced on native tokens.
This PR also includes a full code-review pass (P0–P2 fixes), UX improvements across connection and loading states, and several mobile flow fixes.
Changes
Native token payments (core)
payWithToken, Solana, Stellar): userequired.amount(source-token units), notrequired.usd.tokenBaseAmountToDecimalString/tokenAmountToBaseUnitshandle both integer base-units and decimal strings.preferredAmountUnitscarries the source amount;toUnitsstays the destination payout.amountis type-aware — source-token amount forexactIn, USD/destination forexactOut.formatPaymentResponseToHydratedOrder:usdValue/destFinalCallTokenAmountnow come from the destination. Source amount surfaced viametadata.sourceAmountUnits.lamports/transferAmountstaybigint(noNumber()truncation).logoURIon SelectToken and pay pages (nativeAsChainIcon).Code review fixes (P0/P1)
metadata.sourceAmountUnits: added tozRozoPayOrderMetadataZod schema — removes all(order.metadata as any)?.sourceAmountUnitscasts."sourceAmountUnits missing"instead of silently falling back tousdValue(the wrong USD destination payout).HydrateWalletOptiontype: narrower{ required: { token, amount: string }, fees: { usd } }replaces doubleas anycasts on thehydrateOrderdeposit-address path.store as anyeliminated: narrowedstoreparam type to{ dispatch }— the only field actually used.tokenBaseAmountToDecimalString: widened param fromBigIntStrtostring.bridge-utilsprefferedTokentypo fixed.rozoPaymentResponseundefined on hydrate path:PayWithSolanaTokenfalls back tohydratedOrder.metadata?.sourceAmountUnits.WaitingDepositAddressusesNumber.isFinite+ logs stale price warning.Dual-chain / mobile flow
dualChainConnectflag not cleared: restoredsetDualChainConnect(false)accidentally dropped in refactor — fixes Phantom mobile Solana-only → "Pay with another wallet" doing nothing.setRouteatomic update: mergedroute+routeMetainto a single state object inRozoPayProvider. Eliminates two-render race whereisExplicitBackNavigationcheck fired beforerouteMetaarrived, causing the auto-connect gate to re-navigate the user back toSELECT_TOKENon Phantom mobile payId flows.useWalletPaymentOptions,useSolanaPaymentOptions,useStellarPaymentOptions,useDepositAddressOptionsmigrated to@tanstack/react-query.dualChainConnectstate inPayContext/RozoPayProvider; mobile dual-chain wallet flow viaConnectorList.UX improvements
disabledwhile deposit chains load; chain icons replaced with a spinner (Option.loadingprop onOptionsList).ConnectorStellarerror recovery: "Connection Failed" now shows Try Again + Cancel instead of a dead-end message.ConnectorSolanaerror recovery: no error handling existed — spinner hung forever on rejection/expiry. Added adaptererrorevent listener + same Try Again / Cancel buttons.Infrastructure
generateStellarDeepLink;WaitingDepositAddressgenerates them for Stellar deposit addresses.normalizeTokenAddress(chainId, token)replaces ad-hoc.toLowerCase().workspace:*inpackages/connectkitandexamples/nextjs-app..oxfmtrc.json;AGENTS.mdupdated;CHANGELOG.mdcreated.Tested
pnpm build: 0 TypeScript errors (connectkit + pay-common).pnpm lint: 0 errors on both packages.pnpm --filter @rozoai/intent-common test: 41/41 pass.Follow-ups (not blocking)
WaitingWallet: no cancel path when wallet app doesn't open.WaitingExternal: no timeout/cancel for abandoned exchange flows.SelectTokenempty state: no guidance on why no tokens are available.