Skip to content

Fix - Use EIP-5792 sendCalls for dataSuffix attribution on Base App / Coinbase Wallet#42

Merged
akbarsaputrait merged 19 commits into
masterfrom
feat/eip5792-datasuffix-attribution
Jul 1, 2026
Merged

Fix - Use EIP-5792 sendCalls for dataSuffix attribution on Base App / Coinbase Wallet#42
akbarsaputrait merged 19 commits into
masterfrom
feat/eip5792-datasuffix-attribution

Conversation

@akbarsaputrait

Copy link
Copy Markdown
Member

Summary

Base App and Coinbase Wallet use ERC-4337 smart contract accounts. The previous writeContractAsync + dataSuffix approach works for EOA wallets (MetaMask) but does not produce valid ERC-8021 attribution for ERC-4337 transactions — the suffix must be appended to userOp.callData via the EIP-5792 wallet_sendCalls path.

This PR gates the writeContractsAsync (EIP-5792) path on wallet_getCapabilities — only wallets that advertise dataSuffix support (Base App, Coinbase Wallet) take this path. All other wallets fall back to the existing writeContractAsync + dataSuffix path unchanged.


Changes Made

  • Added useCapabilities() to detect whether the connected wallet supports the EIP-5792 dataSuffix capability for the payment chain
  • Added useWalletClient() as the transport for waitForCallsStatus — routes wallet_getCallsStatus through the wallet provider instead of the public RPC node
  • Added writeContractsAsync (EIP-5792) path in payWithToken for wallets that advertise dataSuffix capability, with a 120s timeout guard
  • Gated the EIP-5792 path on !capabilitiesPending to prevent a race where capabilities haven't resolved yet and a Base App user silently falls through to the EOA path
  • Hoisted walletClient null-check before writeContractsAsync to prevent broadcasting a transaction when the receipt can't be retrieved
  • Removed dead hook scaffolding (useState id, useSendCalls, useCallsStatus, contractStatus effect) that was permanently disabled

How to Test

  1. Open the example app with a valid dataSuffix / builder code configured in getDefaultConfig
  2. Connect Base App or Coinbase Wallet (ERC-4337 smart account)
  3. Select USDC on Base, complete payment
  4. On [builder-code-checker.vercel.app](https://builder-code-checker.vercel.app), enter the resulting hash under UserOperation (AA) mode — should show 8021 Attributed ✅ with your builder code
  5. Connect MetaMask (EOA), repeat — payment should complete normally, same attribution check passes via Transaction mode
  6. Connect any wallet and immediately tap a token before the capabilities request resolves — payment should not silently skip attribution (capabilitiesPending guard)

Testing Checklist

  • Unit tests added/updated
  • Manual testing completed (verified on-chain via UserOperation hash 0xc13cae...)
  • EOA path (MetaMask) unaffected
  • No regressions — build clean

Reviewer Notes

  • waitForCallsStatus from viem/actions requires a WalletClient (wallet provider transport), not a PublicClient. Using useClient() (PublicClient) was the original bug — it routed wallet_getCallsStatus to mainnet.base.org which rejected it with MethodNotFoundRpcError, identical to the MetaMask failure. Fixed by using useWalletClient().
  • Attribution in ERC-4337 flows lives in userOp.callData, not the outer handleOps tx. Checking the Transaction hash on the attribution checker will show "Not attributed" — this is expected. Check the UserOperation (AA) hash instead.
  • bc_mnip appearing alongside your builder code in on-chain data is Base App injecting its own attribution code — expected behavior, does not invalidate yours.

Related

…inbase Wallet

- Gate EIP-5792 path on wallet_getCapabilities (useCapabilities) so only
  wallets that advertise dataSuffix support take this path; MetaMask and
  other EOA wallets fall back to writeContractAsync + dataSuffix unchanged
- Use useWalletClient (wallet provider transport) for waitForCallsStatus
  instead of useClient (PublicClient / mainnet.base.org) which rejected
  wallet_getCallsStatus with MethodNotFoundRpcError
- Guard walletClient null-check before writeContractsAsync to prevent
  broadcasting a tx when the receipt cannot be retrieved
- Add !capabilitiesPending guard to avoid silently falling through to EOA
  path while wallet_getCapabilities is still in-flight
- Use !!chainCapabilities?.dataSuffix instead of "dataSuffix" in obj to
  avoid truthy match on null/false values
- Remove dead hook scaffolding (useState id, useSendCalls, useCallsStatus,
  contractStatus effect) that was permanently disabled
- Add 120s timeout to waitForCallsStatus to prevent infinite hang
@vercel

vercel Bot commented Jun 29, 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 1, 2026 5:20am

Request Review

@akbarsaputrait akbarsaputrait mentioned this pull request Jun 29, 2026

@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: 9fee8f60fe

ℹ️ 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/hooks/usePaymentState.ts
@shawnmuggle

Copy link
Copy Markdown
Member

Review — EIP-5792 dataSuffix attribution

Reviewed via two independent passes (Claude + Codex), cross-checked against viem source (waitForCallsStatus, wallet_getCallsStatus return types) and wagmi useCapabilities semantics. The direction is right and the cleanup (dead useSendCalls/useCallsStatus/contractStatus scaffolding, walletClient null-check hoist, capabilitiesPending guard) is solid. The EOA fallback path is untouched.

Verdict: no P0 blocker — nothing here can silently lose funds on the happy path. But there are 4 P1 should-fixes, two of which are real money-moving risks on the failure paths. For a payments SDK I'd want #1 and #2 fixed before this ships to smart-wallet users.


P1 — reverted batch is reported as a successful payment

usePaymentState.ts:758-764

waitForCallsStatus defaults to throwOnFailure: false, and its terminal condition is statusCode === 200 || statusCode >= 300 — i.e. it resolves (does not throw) when the batch fails on-chain. A reverted ERC-20 transfer (insufficient balance, etc.) still produces a receipt, so result.receipts?.[0] is truthy and the code returns receipts[0].transactionHash → the caller stores the hash and returns { success: true } for a payment that never landed.

Fix: gate on success before trusting the receipt, e.g.

if (result.status !== 'success') throw new Error(`Batched call failed: ${result.status}`);
const hash = result.receipts?.[0]?.transactionHash;
if (!hash) throw new Error("No receipt from batched call");
return hash;

(Per-receipt status is also reverted/success if you want belt-and-suspenders.)

P1 — 120s timeout resets order to unpaid → double-spend window

usePaymentState.ts:758-787

Once writeContractsAsync returns an id, the transfer is already submitted to the wallet/bundler. If waitForCallsStatus then times out (120s), it throws, the catch block calls setPaymentUnpaid(externalId), the user can retry — and the original call may still confirm afterwards, producing a duplicate transfer. Post-submission status-polling failures should not reset to unpaid. Treat timeout as pending/unknown: keep the order in started/pending, persist the call id, and keep polling or surface a recoverable "pending" state rather than reverting.

P1 — !!chainCapabilities?.dataSuffix treats an unsupported capability as supported

usePaymentState.ts:733-737

Standard EIP-5792 capabilities are objects like { supported: false } / { status: 'unsupported' } (cf. atomic, paymasterService in viem's capability types). If a wallet reports dataSuffix: { supported: false }, the truthy-object check passes and we enter the EIP-5792 path anyway. Combined with optional: true below, the wallet may then broadcast the transfer while dropping the suffix. Gate on the explicit shape the target wallets actually return — confirm whether Base App / Coinbase return a bare value or { supported: true }, and check accordingly (=== true / ?.supported === true). (Flagging with mild uncertainty: depends on what Base App/CW actually return — worth a quick capability dump to confirm before changing.)

P1 — optional: true can silently drop attribution

usePaymentState.ts:750-754

optional: true lets a wallet ignore the dataSuffix capability and still broadcast — which defeats the entire purpose of this PR (a real payment with no ERC-8021 attribution, silently). Either set optional: false so a non-honoring wallet fails fast (then fall back to a path that preserves attribution), or verify post-resolution that the suffix was actually included. As-is, the only "proof" attribution worked is the manual on-chain check in the PR description.


P2 — capability decision is made while the query may be pending/stale

usePaymentState.ts:246-247, 733-737

useCapabilities is keyed off the connected connector; right after switchChainAsync or a wallet switch it can be pending/stale. While pending, !capabilitiesPending is false → a supported smart wallet silently falls through to writeContractAsync and loses attribution (the PR frames this as intentional fail-safe, which is defensible — flagging so it's a conscious choice, not a surprise). Also consider gating on !error so an errored capability query can't leave a stale positive in play.


Nits / good to confirm

  • examples/nextjs-app/package.json bumps to @rozoai/intent-pay 0.1.31-beta.1 — confirm the beta is what you want pinned in the example, and that 0.1.20 of intent-common is published.
  • Manual test in the description covers the happy path on both wallet types; the two money-risk paths above (revert + timeout) aren't exercised. Worth a forced-revert test (transfer with insufficient balance) to confirm the order doesn't go to success.

Overall: mergeable once the revert-handling (P1 #1) and timeout/reset (P1 #2) are addressed — those are the two that can mishandle real money.

shawnmuggle
shawnmuggle previously approved these changes Jun 30, 2026
- Updated `useWallets` to ensure mobile in-app browsers can connect to both EVM and Solana wallets.
- Implemented fuzzy matching for `solanaConnectorName` in the mobile branch to allow seamless wallet selection.
- Enhanced `ConnectorList` to initiate connections for both chains simultaneously, improving user experience on mobile devices.
…wser connection issues

- Added guidance for non-Next.js / SPA apps on managing wallet connections without SSR.
- Clarified the root cause and fix for the method picker issue in wallet in-app browsers.
- Updated README to emphasize the need for cookie-persisted initial state for seamless wallet connections.
@shawnmuggle

Copy link
Copy Markdown
Member

Review — EIP-5792 sendCalls for dataSuffix attribution on Base App / Coinbase Wallet

Verdict: APPROVE (no P0) — but two substantive P1s worth resolving before/right after shipping a payments release. I traced the fund-safety concerns (double-broadcast, null-check-before-broadcast, path exclusivity) and none can lose or double-spend funds within a single invocation:

  • Path exclusivity — SAFE. usePaymentState.ts if (supportsDataSuffix) { … return result.receipts[0].transactionHash } returns early; the EOA writeContractAsync fallback is only reachable when supportsDataSuffix is false. Mutually exclusive, no double-send.
  • walletClient null-check — SAFE. if (!walletClient) throw executes before writeContractsAsync, so it can't broadcast-then-fail-to-get-receipt.
  • Return shape — SAFE. 5792 path returns the same hex transactionHash shape the EOA path returns; downstream unchanged.

P1 — should fix

  1. The !capabilitiesPending guard does NOT prevent the attribution-loss bug this PR exists to fix. If wallet_getCapabilities is still pending when a Base App / Coinbase Wallet (ERC-4337) user taps Pay, supportsDataSuffix evaluates false and the code silently falls through to the EOA path — which for a smart account produces no valid ERC-8021 attribution. Nothing in the diff gates the Pay button on !capabilitiesPending, so this window is reachable. Recommend either (a) disable/spin the Pay button while capabilitiesPending for EVM smart-account wallets, or (b) await capability resolution inside the pay handler before choosing a path. (Funds still move correctly — this is attribution loss, not fund loss.)
  2. 120s timeout leaves a UI-vs-chain divergence. On waitForCallsStatus timeout the call throws → payment marked failed, but the batched userOp may still land on-chain afterward; a user who retries could submit a second payment. Whether that's a true double-pay depends on Rozo intent-level idempotency (dedup by paymentId/destination), which isn't visible in this diff. Please confirm downstream dedup covers "UI-failed-but-chain-succeeded", and consider a "check your wallet / pending" state on timeout instead of a hard failure that invites retry.

P1 — verification hygiene

  1. PR description doesn't match the diff. No "dead hook" removal (useSendCalls/useCallsStatus/contractStatus) exists anywhere in the diff (added or removed). And the core EIP-5792 change is in hooks/usePaymentState.ts, not PayWithToken/index.tsx (whose diff is only chain-switch error routing). Please reconcile — for a funds-path change, reviewers/auditors need the description to be trustworthy.

P2

  • useWallets.tsx new explicit Coinbase push + the injected-connector loop could push Coinbase twice if the SDK connector also reports isInjectedConnector inside the Coinbase in-app browser — add a de-dupe against the already-pushed id (UX-only).
  • Version drift: connectkit → 0.1.31-beta.7, but examples/nextjs-app pins @rozoai/intent-pay: 0.1.31-beta.5 (mismatched beta, and skips 0.1.30). Align the example pin and confirm the intended publish tag.
  • errorParser.ts UNSUPPORTED_CHAIN branch is inserted before the generic network check and only matches specific strings — no regression to existing payment-error categorization (verified).
  • Doc drift: PAYMENT_FLOW.md/CLAUDE.md say the auto-navigate fix gates only on "reconnecting", but the code gates on both "reconnecting" and "connecting" (code is more correct — update the doc).

No P0 blockers — approving per the no-P0 gate. Please still address P1 #1 (the capabilitiesPending window defeats the PR's own purpose) and confirm P1 #2 (timeout dedup) since this is a payments release.

shawnmuggle
shawnmuggle previously approved these changes Jul 1, 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 blockers found. See the detailed review comment for P1/P2 follow-ups worth addressing.

@shawnmuggle

Copy link
Copy Markdown
Member

Follow-up review — commit range a5480bc..4e17abd

Verdict: APPROVE. No P0/P1. This range adds standard wagmi SSR cookie-hydration wiring to the example app (async root layout, request cookie → ProviderscookieToInitialState<WagmiProvider initialState>) — correct, low-risk, no secrets.

Checks

  • SSR/hydration (layout.tsx:22): converting RootLayout to async + await headers() is the correct Next.js 15 app-router pattern; the cookie flows server→ProviderscookieToInitialState, which fixes a potential wagmi hydration mismatch rather than introducing one. This opts the root layout into dynamic rendering (expected tradeoff for wagmi SSR — fine for a playground).
  • Provider nesting unchanged (WagmiProvider → QueryClientProvider → ThemeProvider → RozoPayProviderWithTheme); only initialState prop added, no required prop dropped.
  • Secrets: none hardcoded; the E2E Stellar signer referenced nearby is pre-existing and env-gated.

P2 — non-blocking

  1. This range does not touch dataSuffix / ERC-8021 builder-code attribution (the PR's core) — it's SSR plumbing only. Please confirm the dataSuffix config lives elsewhere in the example (it isn't in these two files).
  2. providers.tsx:63-71ssr:true + cookieStorage are now hardcoded on for every build (including E2E), unlike the env-gated Stellar block right below. Not a bug; a one-line comment noting the SSR/cookie persistence is intentional would prevent a future reader assuming it's E2E-only.
  3. Prior round's P2 (example version-pin mismatch 0.1.31-beta.5 vs connectkit beta.7) is not addressed in this range — that's in package.json, outside these two files.

No P0 — approving.

@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 — commit range a5480bc..4e17abd is standard wagmi SSR cookie-hydration wiring for the example app. No P0/P1.

@akbarsaputrait akbarsaputrait merged commit 9093fd0 into master Jul 1, 2026
4 checks passed
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