Skip to content

feat: propagate intent to getFee, carry settlementMode through payId hydration, and support preferredChains in payId mode - #62

Open
akbarsaputrait wants to merge 13 commits into
masterfrom
feat/intent-getfee-propagation
Open

feat: propagate intent to getFee, carry settlementMode through payId hydration, and support preferredChains in payId mode#62
akbarsaputrait wants to merge 13 commits into
masterfrom
feat/intent-getfee-propagation

Conversation

@akbarsaputrait

@akbarsaputrait akbarsaputrait commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

  • getFee and createPayment now build the exact same request body — getFee just adds ?dryrun=true — so a consumer-set intent on RozoPayButton reaches getFee the same way it already reached createPayment. Fee quotes no longer diverge from what createPayment actually charges.
  • formatPaymentResponseToHydratedOrder now copies the backend's settlementMode onto the hydrated order's metadata, so the checkout-mode payId path (runSetPayIdEffectsgetPaymentorder_loaded) carries it through instead of silently dropping it.
  • intent remains entirely consumer-driven — no auto-detection (e.g. stellar_direct) added on the client; that decision stays with the backend via settlementMode in the response.
  • Fixed packages/connectkit's @rozoai/intent-common dependency from a pinned 0.1.26 to workspace:* so local pay-common changes are actually picked up during development.
  • <RozoPayButton payId={...}> now honors preferredChains/preferredTokens/preferredSymbol. Previously these props were silently dropped in payId mode — extracted nowhere in RozoPayButtonCustom's prop-destructure, and usePaymentState's payId-mode stablePayParams derived its token filter purely from the order's destination token, with no path for caller input to reach it. buttonProps/setButtonProps also existed but were never wired up (dead state) — now RozoPayButtonCustom publishes props to it on every change, and stablePayParams, showSolanaPaymentMethod, showStellarPaymentMethod, solanaPaymentEligible, and stellarPaymentEligible all read it as a payId-mode fallback.

Changes

  • packages/pay-common/src/api/payment.ts: extracted buildPaymentRequestBody (shared by createPayment and the new getFee); getFee now takes the same CreateNewPaymentParams shape as createPayment.
  • packages/pay-common/src/api/fee.ts: removed (superseded by payment.ts).
  • packages/pay-common/src/bridge-utils.ts: formatPaymentResponseToHydratedOrder copies settlementMode into hydrated-order metadata.
  • packages/pay-common/src/index.ts: dropped the ./api/fee re-export.
  • packages/pay-common/test/payment.test.ts: new regression test asserting getFee and createPayment build identical request bodies for the same CreateNewPaymentParams input (same-chain, cross-chain with intent). The dryrun query param is the only difference.
  • packages/connectkit/src/utils/feeCache.ts: getCachedFee takes CreateNewPaymentParams; cache key covers the full payload (including intent).
  • packages/connectkit/src/components/Pages/{PayWithToken,Solana/PayWithSolanaToken,Stellar/PayWithStellarToken,WaitingDepositAddress}/index.tsx: fee-quote calls now build a full CreateNewPaymentParams (carrying intent) instead of a separate hand-rolled shape.
  • packages/connectkit/src/components/RozoPayButton/types.ts: payId branch of PayButtonPaymentProps gains preferredChains/preferredTokens/preferredSymbol.
  • packages/connectkit/src/components/RozoPayButton/index.tsx: new effect calls paymentState.setButtonProps(props) on every prop change (previously dead — nothing called it).
  • packages/connectkit/src/hooks/usePaymentState.ts: payId-mode stablePayParams merges buttonProps's preferredChains/preferredTokens/preferredSymbol over the destination-symbol-derived defaults (intersecting tokens by chain when both given, falling back to the full derived set if the intersection is empty); showSolanaPaymentMethod/showStellarPaymentMethod/solanaPaymentEligible/stellarPaymentEligible now read an effective* value (currPayParams ?? buttonProps) and additionally gate on preferredChains.

Design doc: docs/superpowers/specs/2026-08-03-intent-propagation-getfee-design.md

Test plan

  • pnpm buildpay-common (41 tests pass) and connectkit (22 tests pass) both build clean
  • pnpm lint — no new warnings introduced
  • npx tsc --noEmit clean on connectkit after the preferredChains changes
  • getFee/createPayment body-identity invariant — regression test covers same-chain and cross-chain-with-intent cases
  • Manual: exercise a Stellar USDC→USDC payment with an explicit intent override and confirm the fee quote shown before payment matches what createPayment actually charges
  • Manual: payId/checkout flow — confirm settlementMode is available on the hydrated order after runSetPayIdEffects
  • Manual: <RozoPayButton payId={...} preferredChains={[rozoStellar.chainId]}> narrows the modal to Stellar-only tiles/tokens (consumed by rozo-chat-ai's checkout.rozo.ai ?preferredChain= integration)

Additional fix (this update): chain: undefined (id: 8453) on Base transfers

Diagnosed from a production report on intents.rozo.ai — one user hit An unknown RPC error occurred. chain: undefined (id: 8453) 11 times in a row on Base USDC transfers, source-chain always Base, independent of destination (Stellar/Solana/Ethereum all tried). Full trace: 20260810-intent-pay-chain-undefined-base.md.

Root cause (confirmed mechanism, defense-in-depth applied): defaultConfig.ts's chain padding deduped REQUIRED_CHAINS by object identity (Array.includes), not chain.id. A consumer-supplied chain object sourced from a differently-resolved viem copy has the same id but fails the identity check, landing two distinct 8453 entries in config.chains — wagmi's registry lookup can then resolve chain: undefined for a valid id.

  • defaultConfig.ts: dedupe REQUIRED_CHAINS by chain.id, not reference identity. Also adds resolveChainObject(chainId), exported so call sites can resolve the canonical Chain object directly instead of depending on config.chains registry lookup at call time.
  • usePaymentState.ts: the plain writeContractAsync ERC20 transfer path now passes chain explicitly alongside chainId (the batched EIP-5792 writeContractsAsync and native sendTransactionAsync paths don't accept an explicit chain — wagmi's own types Omit it there, so this applies only where possible).
  • connectkit peer range: viem: "2.x"">=2.52.0 <3". hyperEvm is only exported starting viem 2.52 (verified against unpkg); anything below silently resolves undefined into REQUIRED_CHAINS — a second, independent way this exact symptom can occur.
  • pay-common: moved viem from a regular dependency to peerDependency (+ devDependency for its own build/test), removing a realistic vector for a second, differently-resolved viem copy landing in a consumer's tree alongside connectkit's peer-resolved one.
  • examples/nextjs-app: bumped viem range to match the new connectkit floor.
  • Corrected two doc comments (types.ts, useWalletPaymentOptions.ts) that claimed preferredTokens only affects sort order — matchesPreferredTokens actually hard-filters wallet payment options to the preferred set. No behavior change, docs now match the code.

Verification: tsc --noEmit clean, full test suites green (pay-common 41/41, connectkit 22/22), pnpm install --frozen-lockfile resolves cleanly (lockfile already pins viem@2.55.8 everywhere in this repo, so no local breakage). Not yet verified against the actual production failure — that requires the Step 1 repro from the trace doc (pnpm why viem / runtime chain-count check on intents.rozo.ai itself), which needs deploy access this session doesn't have.

Test plan addition:

  • Confirm config.chains.filter(c => c.id === 8453).length === 1 after this change, in the actual intents.rozo.ai bundle
  • Complete one real Base USDC payment end-to-end through intents.rozo.ai
  • Watch PostHog 48h post-deploy: payment_failed with chain: undefined → zero, payment_flow_started flat or rising

intent already reaches createPayment but not getFee, so fee quotes
can diverge from actual settlement (e.g. stellar_direct 0-fee path).
Also fixes connectkit's intent-common dep from pinned 0.1.26 to
workspace:* so local pay-common changes are actually picked up.
getFee and createPayment hit the same endpoint with the same body
(dryrun query param is the only difference), so getFee now takes
CreateNewPaymentParams directly instead of a separate hand-built
GetFeeParams shape. Removes the need for a duplicated stellar_direct
resolution helper - call sites route through buildCreatePaymentPayload
same as the real payment does.
…Id hydration

getFee and createPayment now build the exact same request body (getFee
just adds ?dryrun=true), so a consumer-set intent on the RozoPayButton
reaches getFee the same way it already reached createPayment - fee
quotes no longer diverge from what createPayment actually charges.

formatPaymentResponseToHydratedOrder also now copies the backend's
settlementMode onto the hydrated order's metadata, so the checkout-mode
payId path (runSetPayIdEffects -> getPayment -> order_loaded) carries
it through instead of silently dropping it.
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
intent-example Ignored Ignored Preview Aug 11, 2026 1:54pm

Request Review

Comment on lines +221 to +226
const paymentData = buildPaymentRequestBody(params);

const result = await apiClient.post<FeeResponseData | FeeErrorData>(
"payment-api/payments",
paymentData,
{ params: { dryrun: "true" } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 — behavioral change to the fee request body, with no test guarding the invariant.

Previously getFee sent a minimal, purpose-built body (source/destination with tokenSymbol and a conditional amount, no display/metadata). It now sends the full createPayment body via buildPaymentRequestBodydisplay, metadata, amount on both source and destination unconditionally, and it resolves tokens through getKnownToken. Two consequences worth confirming before merge:

  1. This still posts to payment-api/payments?dryrun=true (unchanged endpoint), but createPayment posts to /payment-api. So the dryrun endpoint must accept the createPayment-shaped body. The PR's manual test checkboxes (Stellar direct/non-direct, explicit intent) are still unchecked — please verify the backend returns the same quote for this new shape, otherwise every fee quote breaks.
  2. The whole PR rests on "getFee and createPayment build identical bodies," and the PR's own suggested-tests list calls for a unit test asserting exactly that — but no test was added. buildPaymentRequestBody/getFee/createPayment have zero direct coverage (only createPaymentBridgeConfig is tested in test/bridge.test.ts). This is fee-quote logic (what the user is told they'll be charged) shipping untested. Please add the invariant test.

export function getCachedFee(
params: CreateNewPaymentParams,
): Promise<FeeResult> {
const key = JSON.stringify(params);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 — unhandled rejection poisons the cache (now reachable). getCachedFee stores a {status:"pending"} entry (below) whose promise is only cleaned up inside .then (fulfillment). If getFee rejects, the pending entry is never deleted and has no TTL, so every subsequent call with the same key returns the stale rejected promise. This matters more now: getFeebuildPaymentRequestBody (packages/pay-common/src/api/payment.ts) throws "Source or destination token not found" on an unknown token — a synchronous throw the old getFee never had. In PayWith*Token, setFeeLoading(false) is also skipped on that path, so the spinner can stick. Add a .catch/.finally that cache.delete(key) on rejection.

Also: key = JSON.stringify(params) now hashes the entire CreateNewPaymentParams. It's stable as long as every call site builds the object with identical key ordering (they do today), but it's more fragile than the old explicit-field key — a metadata/title field slipping in would silently fragment the cache.

setApiConfig({ version: params.apiVersion });
}

const paymentData = buildPaymentRequestBody(params);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 (note). The createPayment refactor extracts the body construction into buildPaymentRequestBody verbatim, which is good — behavior looks preserved (setApiConfig still runs first, then body build). Just flagging that createPayment posts to /payment-api while getFee posts to payment-api/payments?dryrun=true — so the PR summary's "getFee just adds ?dryrun=true" is slightly inaccurate (different path too). Not a bug since this matches the pre-existing fee endpoint, just worth correcting in the description.

@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: e2d5cc69d3

ℹ️ 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 on lines +224 to +226
...(paymentState.payParams?.intent
? { intent: paymentState.payParams.intent }
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve auto direct-settlement intent for Stellar quotes

When the payer selects the Stellar token that matches a Stellar destination and the integrator did not explicitly pass payParams.intent, this fee request omits the intent: "stellar_direct" that buildCreatePaymentPayload adds for the actual payment. The dry-run therefore still uses the bridge/fee route, so the user can see/use a nonzero fee even though checkout/createPayment will settle direct; for ExactOut, that quoted fee is fed back into payment creation and can reduce the destination amount. Build the fee params through the same helper or duplicate the auto-detection before calling getCachedFee.

Useful? React with 👍 / 👎.

Comment on lines +376 to +379
preferredChain: selectedDepositAddressOption.token.chainId,
preferredTokenAddress: selectedDepositAddressOption.token.token,
toUnits: amount.toString(),
...(payParams?.intent ? { intent: payParams.intent } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve direct-settlement intent for deposit quotes

For the Stellar deposit-address option with a Stellar USDC/EURC destination and no explicit payParams.intent, this hand-built quote request also omits the auto stellar_direct intent that the later hydrate/create-payment path derives via buildCreatePaymentPayload. That means the dry-run can return a bridge fee for a direct settlement; the returned fee is passed into payWithDepositAddress, so ExactOut can create a lower payout than intended and the default flow displays the wrong fee. Reuse the same payload builder for the quote so the intent matches the payment.

Useful? React with 👍 / 👎.

PayWithToken, PayWithSolanaToken, PayWithStellarToken, and
WaitingDepositAddress each hand-built an identical CreateNewPaymentParams
object for fee quotes. Centralize that construction in feeCache.ts so a
future field on CreateNewPaymentParams (or a change to appId/intent
resolution) only needs to be wired in one place.
Comment on lines +121 to +131
return {
appId: resolveOrderAppId(order, payParams?.appId) ?? "",
feeType: payParams?.feeType ?? FeeType.ExactIn,
toChain: destChainId,
toToken: destTokenAddress,
toAddress: destAddress || payParams?.toAddress || "",
preferredChain: sourceChainId,
preferredTokenAddress: sourceTokenAddress,
toUnits,
...(payParams?.intent ? { intent: payParams.intent } : {}),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 — behavior change: appId is now always sent, even when empty.

When resolveOrderAppId(...) returns undefined, this emits appId: "", and buildPaymentRequestBody then copies it into metadata.appId too. The removed fee.ts#getFee did the opposite — ...(appId ? { appId } : {}) omitted it entirely when falsy.

For a dry-run quote this is likely harmless, but if the backend treats appId: "" differently from an absent appId (auth/attribution), the fee quote could diverge from what createPayment sends via the real payment payload. Worth confirming the backend ignores empty appId, or gate it the same way (...(resolvedAppId ? { appId: resolvedAppId } : {})).

Comment on lines +26 to +29
export function getCachedFee(
params: CreateNewPaymentParams,
): Promise<FeeResult> {
const key = JSON.stringify(params);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 — rejected getFee now permanently poisons this cache.

This function's getFee (line 45, unchanged) is getFee(params).then(...) with no rejection handler, and the pending entry (line 60) has no TTL. That was safe before because the old fee.ts#getFee never threw — it only returned { error }.

The new getFee calls buildPaymentRequestBodycreatePaymentBridgeConfig / getKnownToken, which throw for unsupported tokens, invalid addresses, or a getKnownToken miss ("Source or destination token not found"). If getFee rejects, the cache keeps { status: "pending", promise: <rejected> } forever, and every later call with the same JSON.stringify(params) key returns that rejected promise (line 41) — the payer can never get a quote for that token again this session, retries included.

Fix: add a .catch on the getFee(params) chain (line 45) that cache.delete(key) and rethrows, mirroring the existing result.error cleanup at line 55.

* source/destination/type/intent resolution), with a dryrun flag, so the
* quote always matches what createPayment will actually charge.
*
* @param params - Same shape as createPayment's params

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 — missing test for the invariant the whole change rests on.

The design doc (docs/.../2026-08-03-intent-propagation-getfee-design.md, "Testing") explicitly calls for a pay-common test asserting getFee and createPayment build identical request bodies for the same CreateNewPaymentParams (minus the dryrun query param) — "the invariant the whole fix rests on." No such test was added, and the PR's own test-plan checkbox for it is unchecked.

Since getFee now silently posts a different body shape than the old fee.ts version (symbol-based → the full buildPaymentRequestBody payload), and both branches feed a live pricing endpoint, this is exactly the kind of change that needs a regression test. A single assertEqual(buildPaymentRequestBody(p), <expected>) (or a getFeecreatePayment body-equality check) in packages/pay-common/test/ would lock it in.

@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review summary — PR #62 (intent → getFee propagation)

Solid, well-scoped refactor. Collapsing getFee onto the same buildPaymentRequestBody payload createPayment uses is the right way to guarantee the quote matches the charge; buildPaymentRequestBody is extracted verbatim (no change to the money-moving path), setApiConfig is correctly hoisted into both callers, the settlementMode propagation in formatPaymentResponseToHydratedOrder is additive and consistent, and the public API surface (getFee, FeeResponseData, FeeErrorData) is preserved via the payment.ts re-export so dropping ./api/fee doesn't break importers.

No P0 findings — no secrets, no auth/RLS changes, no edge-function deploys, no mirror-table writes, no blacklisted receiver, and the actual settlement path is unchanged.

Findings

P1

  • feeCache.tsgetCachedFee has no rejection handler on the getFee(...) chain, so a thrown getFee (the new payload path can throw via buildPaymentRequestBody/createPaymentBridgeConfig/getKnownToken, unlike the old fee.ts) leaves a pending entry that never expires and permanently poisons that params key for the session. Add a .catch that deletes the key and rethrows.
  • packages/pay-common — the design doc's core invariant (getFee and createPayment build identical bodies) has no test, and the PR's own test-plan item for it is unchecked. This is the assertion the whole change rests on; a body-equality regression test in test/ should land with it.

P2

  • feeCache.ts:121-131buildFeeQuoteParams now always emits appId: \"\" (and metadata.appId: \"\") when the appId can't be resolved, whereas old fee.ts#getFee omitted appId when falsy. Harmless if the backend treats empty == absent for dry-runs; worth confirming or gating.
  • packages/connectkit/package.json@rozoai/intent-common `0.1.26 → workspace:` is correct for local dev; just ensure the release flow publishes both packages in lockstep so the published `connectkit` doesn't resolve `workspace:` to an unpublished `pay-common` version.

Not approving/merging — verdict left to the automated step.

…ffect

PayParamsData (the state persisted between preview and payment
creation) had no `intent` field, so a top-level intent flag set via
RozoPayButton's `intent` prop — e.g. "stellarsponsor" — was silently
dropped before createPayment ever ran, for any flow that reaches
hydrate_order (EVM PayWithToken). metadata.intent (the unrelated
display title) survived, masking the loss: a request built this way
carries a title but no sponsorship signal.

PayWithSolanaToken and PayWithStellarToken were unaffected — they call
paymentState.createPayment (full PayParams) directly, not this path.

Adds `intent` to PayParamsData so a future regression here is a
compile error, not silent data loss, and extracts the hydrate_order
payload builder (buildHydratePayParamsPayload) so it's independently
testable.
@akbarsaputrait akbarsaputrait changed the title feat: propagate intent to getFee and carry settlementMode through payId hydration feat: propagate intent to getFee, carry settlementMode through payId hydration, and support preferredChains in payId mode Aug 8, 2026
@shawnmuggle

Copy link
Copy Markdown
Member

Review — no P0, but 5 × P1. Recommend not merging as-is.

Verified against head 5a88dd9, plus the published tarballs @rozoai/intent-common@0.1.26-beta.3 / @0.1.27-beta.2 and @rozoai/intent-pay@0.1.40-beta.4.

No P0. No path was found where settlementMode or preferredChain(s) sends funds to the wrong chain or address — the destination on every fee path is still currentOrder.destFinalCallTokenAmount + getCanonicalDestination(order), and preferredChains is purely a source-side UI filter. Importantly, the on-chain amount is walletPaymentOption.required.usd (usePaymentState.ts:1208), not the quoted fee — so a wrong fee quote is a display/accounting bug, not an overpayment. That refutes the escalation path in the codex bot's two comments.

P1-1 — the headline bug is not actually fixed: stellar_direct never reaches getFee

utils/feeCache.ts:117-125 (buildFeeQuoteParams) vs payment/createPaymentPayload.ts:239-251.
buildCreatePaymentPayload force-sets intent: "stellar_direct" whenever source and destination are the same Stellar token (isStellarSameToken && isSupportedStellarToken), overriding any consumer value. buildFeeQuoteParams only forwards payParams?.intent and has no such derivation — and in payId/checkout mode stablePayParams (usePaymentState.ts:455-462) never sets intent at all, so this is the default path.
Repro: Stellar USDC → Stellar USDC. getFee posts no intent → backend quotes the bridge/hub route with a nonzero fee → PayWithStellarToken/index.tsx:240 stores it and PaymentBreakdown:644-652 shows the user a fee; createPayment then settles stellar_direct at 0.00.
Fix: run the same isStellarDirect derivation inside buildFeeQuoteParams, or have it consume the payload from buildCreatePaymentPayload directly. (This is the single root cause behind both of codex's stellar_direct P2s.)

P1-2 — breaking SDK export with a live in-org consumer, shipped as a patch bump

packages/pay-common/src/api/fee.ts deleted; index.ts:2 drops export * from "./api/fee". Confirmed on the tarballs: 0.1.26-beta.3 exports getFee(params: GetFeeParams); 0.1.27-beta.2 no longer re-exports ./api/fee, so root getFee is now (params: CreateNewPaymentParams) and GetFeeParams is gone from the public surface.
Confirmed consumer: RozoAI/rozo-invoicecomponents/stellar-scan-content.tsx:24,287-296 imports getFee from @rozoai/intent-common and builds the old shape. It pins 0.1.26 so it isn't broken today, but any bump breaks its tsc build; forced through at runtime, buildPaymentRequestBody destructures toChain: undefinedgetChainById(undefined) throws.
This is semver-major shipped as 0.1.39 → 0.1.40-beta.4. Minimum: keep a deprecated getFeeLegacy(GetFeeParams) shim and re-export GetFeeParams, or land the matching rozo-invoice PR alongside. (rozo-intents-demo declares its own local GetFeeParams; FeeResponseData/FeeErrorData survive via export * from "./api/payment" — not breaking.)

P1-3 — getFee can now reject, and a rejection permanently poisons the fee cache (confirms claude[bot])

utils/feeCache.ts:45-61: getFee(params).then(...) has no .catch, the {status:"pending"} entry has no TTL, and only the .then branch deletes it. The old fee.ts#getFee posted raw symbols and could not throw. The new one → buildPaymentRequestBodycreatePaymentBridgeConfig throws on unknown destination token (bridge-utils.ts:168), invalid destination address (:174 — reachable because buildFeeQuoteParams falls back to ""), unknown preferred token (:190), and EURC↔non-EURC mismatch (:200-210).
Repro: one order with an unresolvable source token rejects → the pending entry survives forever → every later quote with the same key returns that rejected promise for the life of the page, even after a fresh retry. Compounded by setFeeLoading(false) (PayWithToken/index.tsx:180) not being in a finally, so the spinner sticks. Fix: .catch(e => { cache.delete(key); throw e }) + try/finally.

P1-4 — connectkit is pinned to a published intent-common, not workspace:* — so the PR's central invariant was never built

packages/connectkit/package.json:52 + pnpm-lock.yaml: @rozoai/intent-common goes 0.1.26 → 0.1.27-beta.2. The PR body says it was changed to workspace:* "so local pay-common changes are actually picked up." It wasn't. The pnpm build / tsc --noEmit in the test plan validated connectkit against an npm tarball, not against the packages/pay-common source in this same diff. (0.1.27-beta.2 happens to match — but that's luck, not CI, and any further pay-common edit on this branch is silently untested.)

P1-5 — no test asserts the getFee and createPayment bodies are identical (confirms claude[bot])

test/createPaymentPayload.test.ts adds only intent-forwarding tests. The design doc shipped in this PR calls the identical-body assertion "the invariant the whole fix rests on"; it's absent, and the PR's own checkbox is unchecked. P1-1 is exactly what that test would have caught.

P2

  • The bodies are demonstrably not identical. feeCache.ts:124 passes toUnits: option.required.usd (source-side USD, fee included); buildCreatePaymentPayload passes formatUnits(safeAtomic, tokenDecimals) (destination amount). buildPaymentRequestBody:112-129 writes that one value to both source.amount and destination.amount — for an EURC destination a USD figure is posted as a EUR amount. Pre-existing, but it contradicts "getFee just adds ?dryrun=true."
  • Amount is now sent unconditionally on both sides. Old fee.ts sent source.amount only for ExactIn/AnyAmount and destination.amount only for ExactOut. If the backend disambiguates fee type by which side carries an amount, quotes shift — worth confirming against payment-api/payments?dryrun=true before merge.
  • Dropped defensive stellar → rozoStellar source mapping. Old fee.ts:47-52 mapped both Solana and Stellar; createPaymentBridgeConfig (bridge-utils.ts:181-189,226-235) maps only Solana. IDs are distinct (stellar=10001, rozoStellar=1500) and useStellarPaymentOptions.ts:32,62 admits both. Latent today (deposit-address options use rozoSolana* only), but restore the guard.
  • appId: "" is now always sent (feeCache.ts:117, copied to metadata.appId at payment.ts:136-137); old code omitted it when falsy. Confirms claude[bot] — harmless only if the backend treats "" as absent for attribution.
  • stablePayParams is no longer stable. RozoPayButton/index.tsx:260-264: useEffect(..., [props, JSON.stringify(props)]) — the props identity dep makes the stringify dead weight, so a parent inlining preferredChains={[…]} (exactly what rozo-chat-ai does) churns useWalletPaymentOptions refetches. JSON.stringify also throws on circular structures if a consumer ever passes a React element prop. Use a shallow key over the five fields actually consumed.
  • Inconsistent fallback for an unsupported preferredChains. usePaymentState.ts:450-453 falls back to the full derived set when the intersection is empty (good), but showSolanaPaymentMethod (:337-339), showStellarPaymentMethod (:356-358), solanaPaymentEligible (:383-385) and stellarPaymentEligible (:392-394) hard-return false with no fallback. Repro: Stellar-EURC destination + preferredChains=[rozoSolana.chainId] → token list falls back to Stellar EURC, but the Stellar tile is hidden and a Solana tile shows with no payable token. Checkout dead-ends — and this is reachable from an untrusted URL via rozo-chat-ai#16.
  • settlementMode on the hydrated order is write-only. bridge-utils.ts:431 adds it under an as any; nothing in intent-pay reads hydratedOrder.metadata.settlementMode (the PayWith*Token flows read res.settlementMode off the raw response and only log() it). Forward-looking plumbing, not a fix — the PR body overstates it.
  • buttonProps is never cleared on unmount (RozoPayButton/index.tsx:262), so an unmounted button leaves its preferredChains in provider state for a later payId-mode button that passed none.

paddedChains.includes(chain) compares by reference. A consumer app
passing its own chain object (e.g. base imported from a different
resolved copy of viem/wagmi — realistic in pnpm monorepos with loose
peer ranges) has the same chain.id but fails the identity check, so
REQUIRED_CHAINS pushes a duplicate entry for that id. wagmi's
createConfig then holds two distinct objects for one chain id, which
can surface as viem's "chain: undefined (id: 8453)" during RPC/wallet
client construction — reported on intents.rozo.ai as 11 failed Base
USDC transfers from one user (2026-08-09 session).

Fix is correct regardless of whether that's the confirmed mechanism
for this specific report: dedupe should always be by id, not by
reference. See 20260810-intent-pay-chain-undefined-base.md step 2a.
…n viem peer range

Diagnosed from a production report on intents.rozo.ai: one user hit
"An unknown RPC error occurred. chain: undefined (id: 8453)" 11 times
in a row on Base USDC transfers, all source-chain, independent of
destination. See scratch doc 20260810-intent-pay-chain-undefined-base.md
(step 2a/2b/2c).

- defaultConfig.ts: add resolveChainObject(chainId), exported so call
  sites can resolve the canonical Chain object instead of depending on
  wagmi's config.chains registry lookup at call time.
- usePaymentState.ts: pass `chain` explicitly on the plain
  writeContractAsync ERC20 transfer path, alongside chainId. (The
  batched EIP-5792 writeContractsAsync and native sendTransactionAsync
  paths don't accept an explicit chain — wagmi's own types Omit it
  there, so this only applies to the one call site where it's possible.)
- connectkit peerDependencies: viem "2.x" -> ">=2.52.0 <3". hyperEvm is
  only exported starting viem 2.52 (verified against unpkg); anything
  below that silently resolves undefined into REQUIRED_CHAINS.
- pay-common: move viem from a regular dependency to peerDependency (+
  devDependency for its own build/test), so it can't land a second,
  differently-resolved viem copy in a consumer's tree alongside
  connectkit's peer-resolved one — a realistic duplicate-object vector
  for the chain-identity dedupe bug fixed separately in defaultConfig.ts
  (paddedChains dedup by chain.id, already committed).
- examples/nextjs-app: bump viem range to match the new connectkit floor.

Also corrects two doc comments that claimed preferredTokens only
affects sort order — useWalletPaymentOptions.ts's matchesPreferredTokens
actually hard-filters wallet payment options to the preferred set. No
behavior change; docs now describe what the code does.

Verified: tsc --noEmit clean and full test suite green in both
pay-common (35/35) and connectkit (22/22). pnpm install --frozen-lockfile
still resolves cleanly (lockfile already pins viem@2.55.8 everywhere).
Asserts getFee and createPayment build identical request bodies for the
same CreateNewPaymentParams input (same-chain, cross-chain with intent).
The dryrun query param is the only difference between the two calls.
…gacy export, workspace:* dep, buttonProps cleanup
@shawnmuggle

Copy link
Copy Markdown
Member

Review — 1 P0, please fix before merge

Reviewed at head 2e86042. The settlementMode propagation, REQUIRED_CHAINS id-dedupe, and adding intent to PayParamsData are all correct and worth keeping. But there is one money-correctness issue and several should-fixes:

P0

1. Fee quote params ≠ createPayment params — the headline invariant doesn't hold at the call sites.
packages/connectkit/src/utils/feeCache.ts:110-160 (buildFeeQuoteParams) vs packages/connectkit/src/payment/createPaymentPayload.ts:130-260 (buildCreatePaymentPayload) build the request bodies independently, with three material divergences:

  • toUnits: fee path uses option.required.usd (source, fee-inclusive amount); createPayment uses formatUnits(order.destFinalCallTokenAmount.amount) (destination amount). Any cross-chain order where source ≠ dest quotes the fee on the wrong notional → displayed fee can differ from the actual charge.
  • ExactOut: createPayment subtracts feeAtomic when feeType !== ExactIn; the fee path never does.
  • appId: fee path falls back to "" (feeCache.ts:151), createPayment falls back to DEFAULT_ROZO_APP_ID (createPaymentPayload.ts:132) → app-tiered fees quote under the wrong appId.

The new test packages/pay-common/test/payment.test.ts:16 only proves the two functions agree given identical params, so it cannot catch this. Suggest: have both call sites share one param-builder (see also P2 #10).

P1

  1. pay-common/src/api/payment.ts:205getFee now runs buildPaymentRequestBody, which throws "Source or destination token not found" (payment.ts:101) for tokens missing from getKnownToken; old getFeeLegacy never threw. The throw escapes getCachedFee (feeCache.ts:52) synchronously → deposit-address / exotic-token flows that previously showed a fee now hard-error.
  2. connectkit/src/components/RozoPayButton/index.tsx:296-306setButtonProps writes a single shared context slot and cleanup unconditionally clears it. Two <RozoPayButton>s on one page: last-rendered wins; opening A's modal can apply B's preferredChains, or B's unmount blanks A's.
  3. connectkit/src/hooks/usePaymentState.ts:331-405preferredChains gates only compare rozoSolana.chainId (900) / rozoStellar.chainId (1500). Passing solana.chainId (501) or stellar.chainId (10001) — both exported and used elsewhere — silently hides the tile the caller asked for. Accept both id families.
  4. usePaymentState.ts:447-452 — empty preferredChains ∩ preferredTokens intersection falls back to the full token set, but the tile gates above still hide Solana/Stellar → a mistyped chain id yields a half-filtered modal instead of a clean no-op.
  5. pay-common/src/api/fee.ts:33 + pay-common/package.jsongetFee renamed/re-signed under a patch bump (0.1.26 → 0.1.27). External importers of getFee(GetFeeParams) break at runtime (and hit the throw in refactor: complete SDK rebranding to Rozo #2). Needs a minor bump + release note.
  6. pay-common/package.json:31-48viem moved to peerDependencies with floor ^2.23.2, while connectkit requires >=2.52.0 <3. A consumer on viem 2.30 resolves hyperEvm to undefined — the exact failure this PR is closing. Align both floors.
  7. connectkit/package.json:52 — pins @rozoai/intent-common: 0.1.27 (not workspace:* as the PR body says); intent-pay@0.1.40 is uninstallable until intent-common@0.1.27 is published. Enforce release ordering.

P2

  1. usePaymentState.ts:904chain: resolveChainObject(...) passes an explicit chain: undefined for consumer-supplied custom chains; prefer conditional spread.
  2. feeCache.ts:135-150stellar_direct derivation copy-pasted from createPaymentPayload.ts:236-247; extract a shared helper (this duplication is how P0 Sync with daimo #1 happened).
  3. feeCache.ts:36 — cache key is raw JSON.stringify(params): key-order sensitive.
  4. connectkit/bundle-analysis.html — build artifact committed in the diff; fee.ts missing trailing newline.

Not approving while the P0 stands; happy to re-review after a fix.


Reviewed with Claude Code (deep local review, read-only against head 2e86042).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants