diff --git a/.agent/context/20260913T-privy-wallet-payment-hang.md b/.agent/context/20260913T-privy-wallet-payment-hang.md new file mode 100644 index 0000000..df8eb50 --- /dev/null +++ b/.agent/context/20260913T-privy-wallet-payment-hang.md @@ -0,0 +1,49 @@ +# Privy wallet payment hang — 2026-09-13 + +## Goal + +Make the OneShot user-wallet payment flow work for Privy-authenticated users and for EVM wallets connected through Privy, including Arc Testnet transactions. + +## Acceptance criteria + +- A Privy login for a user without a linked wallet creates an embedded Ethereum wallet. +- A connected MetaMask or other EVM wallet is selected even when Privy has no active-wallet value. +- Arc Testnet remains the selected chain (`5042002`) and the ERC-20 USDC transfer path is unchanged. +- Existing payment safety invariants remain intact: one intent, replay-safe attempts, and at most one committed settlement. +- Web tests and type checks pass, or pre-existing failures are documented. + +## Evidence and diagnosis + +- The quote request succeeds; the indefinite spinner starts in `JobWorkspace.start()` while resolving the user wallet. +- `apps/web/src/auth/privy-session.tsx` configured `embeddedWallets.ethereum.createOnLogin` as `off`. With no external wallet in an incognito session, `connectWallet({ reset: true })` can remain pending on the wallet picker. +- The wallet fallback only searched for `walletClientType === 'privy'`, so it could miss a connected MetaMask/Rainbow/Coinbase EVM wallet returned by `useWallets()`. +- The public site bundle and the repository default use Privy app ID `cmtqbf5zo013w0cky3r0jqjca`, while the dashboard policy URL supplied by the user is app `cmtvo63u300640cjwcbyyonv3`. These are different Privy apps; the policy must be created in the app actually used by the deployed frontend/backend, or the app IDs must be aligned before deployment. +- The current Privy wallet policy allows `eth_sendTransaction` on Arc Testnet chain ID `5042002`; the Arc Testnet wallet was funded successfully, so the observed issue is in wallet selection/configuration rather than the faucet balance. + +## Change scope + +- Update Privy embedded-wallet creation to `users-without-wallets`. +- Restrict the Privy wallet picker to Ethereum wallets. +- Select the first connected Ethereum wallet as a safe fallback when there is no active wallet. +- Keep the existing transaction encoding, prepare/submit endpoints, receipt verification, and idempotency logic unchanged. + +## Non-goals + +- No production deployment. +- No testnet transaction approval from the browser. +- No change to backend settlement or Arc USDC contract handling. + +## Verification plan + +- Focused `apps/web` Privy session tests. +- Web typecheck, lint, and build. +- Re-run the web test suite and record any unrelated baseline failures. + +## Verification results + +- Focused Privy session tests: 7 passed. +- Full web test suite: 16 files and 88 tests passed. +- Web typecheck: passed. +- Web lint: passed. +- Web production build: passed; Vite emitted only the existing large-chunk warning. +- `git diff --check`: passed. diff --git a/apps/web/src/auth/privy-session.tsx b/apps/web/src/auth/privy-session.tsx index c384bbd..b94c80b 100644 --- a/apps/web/src/auth/privy-session.tsx +++ b/apps/web/src/auth/privy-session.tsx @@ -5,7 +5,7 @@ import { usePrivy, useWallets, } from '@privy-io/react-auth'; -import type { BaseConnectedWalletType, ConnectedWallet } from '@privy-io/react-auth'; +import type { BaseConnectedWalletType } from '@privy-io/react-auth'; import { defineChain } from 'viem'; import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { OperatorSession, OperatorSessionStatus, UserWalletSession } from './session.js'; @@ -30,13 +30,14 @@ export function PrivyOperatorProvider(props: { appId={props.appId} config={{ loginMethods: ['email', 'wallet'], - embeddedWallets: { ethereum: { createOnLogin: 'off' } }, + embeddedWallets: { ethereum: { createOnLogin: 'users-without-wallets' } }, defaultChain: ARC_TESTNET, supportedChains: [ARC_TESTNET], appearance: { theme: '#0a0a0a', accentColor: '#00dc5f', walletList: ['detected_ethereum_wallets', 'wallet_connect'], + walletChainType: 'ethereum-only', }, }} > @@ -96,10 +97,8 @@ function transferData(recipient: string, amountAtomic: string): `0x${string}` { } type EthereumWallet = Extract; -function isPrivyEthereumWallet( - value: BaseConnectedWalletType | undefined, -): value is ConnectedWallet { - return value?.type === 'ethereum' && value.walletClientType === 'privy'; +function isEthereumWallet(value: BaseConnectedWalletType | undefined): value is EthereumWallet { + return value?.type === 'ethereum'; } export function usePrivyUserWallet(): UserWalletSession { @@ -115,7 +114,7 @@ export function usePrivyUserWallet(): UserWalletSession { activeWallet?.type === 'ethereum' ? activeWallet : walletsReady - ? wallets.find((candidate) => isPrivyEthereumWallet(candidate)) + ? wallets.find((candidate) => isEthereumWallet(candidate)) : undefined; async function selectWallet(): Promise { diff --git a/apps/web/test/privy-session.test.tsx b/apps/web/test/privy-session.test.tsx index cf59ab7..d4a1807 100644 --- a/apps/web/test/privy-session.test.tsx +++ b/apps/web/test/privy-session.test.tsx @@ -99,7 +99,7 @@ describe('usePrivyOperatorSession — native Privy login', () => { expect(mocks.getAccessToken).toHaveBeenCalledOnce(); }); - it('keeps automatic wallet creation off and configures Arc Testnet', () => { + it('creates an embedded wallet for users without one and configures Arc Testnet', () => { render(
@@ -107,11 +107,12 @@ describe('usePrivyOperatorSession — native Privy login', () => { ); expect(mocks.providerConfig).toMatchObject({ - embeddedWallets: { ethereum: { createOnLogin: 'off' } }, + embeddedWallets: { ethereum: { createOnLogin: 'users-without-wallets' } }, defaultChain: { id: 5042002 }, supportedChains: [{ id: 5042002 }], appearance: { walletList: ['detected_ethereum_wallets', 'wallet_connect'], + walletChainType: 'ethereum-only', }, }); }); @@ -139,7 +140,27 @@ describe('usePrivyOperatorSession — native Privy login', () => { ); expect(privy.request).not.toHaveBeenCalled(); - expect(first.request).not.toHaveBeenCalled(); + }); + + it('uses the first connected Ethereum wallet when Privy has no active wallet', async () => { + const metamask = ethereumWallet('metamask', '0x1111111111111111111111111111111111111111'); + mocks.wallets = [metamask.wallet]; + + const { result } = renderHook(() => usePrivyUserWallet()); + expect(result.current.address).toBe(metamask.wallet.address); + expect(mocks.active.connect).not.toHaveBeenCalled(); + + await result.current.sendTransfer({ + chain_id: 5042002, + token_contract: '0x3600000000000000000000000000000000000000', + payer_wallet: metamask.wallet.address, + recipient: '0x3333333333333333333333333333333333333333', + amount_atomic: '10000', + }); + + expect(metamask.request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'eth_sendTransaction' }), + ); }); it('opens the Privy wallet picker when no wallet is active', async () => { @@ -163,12 +184,6 @@ describe('usePrivyOperatorSession — native Privy login', () => { expect.objectContaining({ method: 'eth_sendTransaction' }), ); - expect(typedData.types?.EIP712Domain).toEqual([ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ]); }); it('forgets a picker wallet when the signed-in Privy user changes', async () => {