Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .agent/context/20260913T-privy-wallet-payment-hang.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 6 additions & 7 deletions apps/web/src/auth/privy-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
},
}}
>
Expand Down Expand Up @@ -96,10 +97,8 @@ function transferData(recipient: string, amountAtomic: string): `0x${string}` {
}

type EthereumWallet = Extract<BaseConnectedWalletType, { readonly type: 'ethereum' }>;
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 {
Expand All @@ -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<EthereumWallet | undefined> {
Expand Down
33 changes: 24 additions & 9 deletions apps/web/test/privy-session.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,20 @@ 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(
<PrivyOperatorProvider appId="test-app">
<div />
</PrivyOperatorProvider>,
);

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',
},
});
});
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading