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
82 changes: 82 additions & 0 deletions .agent/context/20260912T225825Z-restore-privy-payment-wallets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Session Context: restore-privy-payment-wallets

## Date/time

- UTC: 2026-09-12T22:58:25Z

## User goal

Restore multi-wallet payment support on the web client: detected wallets and
WalletConnect in the Privy picker, payments bound to the actively selected
wallet, wallet picker when none is active, MetaMask-compatible EIP-712 x402
signing, preserving newer payment/recovery work.

## Original prompt/request

Implementer handoff (restated): "Ready for review. Stopped before Gate A/B —
neither was started. Implemented on branch fix/restore-privy-payment-wallets:
restored detected wallets plus WalletConnect; payments now use the actively
selected wallet; restored wallet picker when none is active; added
MetaMask-compatible EIP-712 x402 signing; preserved newer payment/recovery
work." User then instructed: create the draft PR and run a Gate B check.

## Assumptions

- Preferring the active wallet over the embedded Privy wallet intentionally
supersedes the silent-embedded preference from PR #108/#109.
- Draft PR and Gate B may proceed without Gate A by explicit user instruction.

## Plan

1. Independent pre-push review of the uncommitted diff (done in-session).
2. Record context, commit, push, create draft PR targeting develop.
3. Wait for required CI, then run Gate B-style review and record its verdict.

## Key decisions

- Commit message follows repo convention: fix(web) subject.
- Gate A skipped only by explicit user instruction; compensating evidence is an
independent in-session review plus full local validation reproduction.

## Files/components touched

- apps/web/src/auth/privy-session.tsx — active-wallet binding, WalletConnect in
walletList, EIP712Domain types in raw eth_signTypedData_v4 payload.
- apps/web/test/privy-session.test.tsx — tests updated for the new semantics.

## Commands/checks

- `pnpm vitest run test/privy-session.test.tsx` (apps/web) - PASS, 12/12
- `pnpm typecheck` (apps/web) - PASS (tsc -b exit 0)
- `pnpm lint` (apps/web) - PASS (eslint exit 0)
- `git diff --check` - PASS
- Local Node 22.23.2 differs from repository Node 24.19.0; checks still passed.

## External-doc findings

- EIP-712: eth_signTypedData_v4 JSON must declare an EIP712Domain type set that
matches the domain members; the typed x402 domain has exactly name, version,
chainId, verifyingContract, so the injected set matches (uint256 chainId).

## Unresolved questions

- Runtime smoke test of a real WalletConnect session (Privy walletConnect
projectId configuration) is not covered by unit tests.

## Git and PR state

- Branch: fix/restore-privy-payment-wallets
- Base: develop (37e4615cc7a570f70f6f6ae9d49ac68d96549619, origin/develop tip)
- Commit: created together with this record (branch tip; SHA in PR evidence)
- PR: draft created after push (URL recorded in PR body)
- CI: pending at push time; results recorded on the PR

## Gate A/B state

- Gate A: SKIPPED by explicit user instruction. Independent in-session pre-push
review found no blocking findings; two non-blocking notes (WalletConnect
projectId runtime check; intentional reversal of the PR #109 wallet
preference needs product ack).
- Gate B: run after push and CI per freepi-pr-review.md; verdict recorded on
the PR. Expected fail-closed on Gate A evidence completeness if CI/gate
prerequisites are incomplete.
37 changes: 13 additions & 24 deletions apps/web/src/auth/privy-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import {
useActiveWallet,
useLogin,
usePrivy,
useWallets,
type BaseConnectedWalletType,
type ConnectedWallet,
} from '@privy-io/react-auth';
import { BatchEvmScheme } from '@circle-fin/x402-batching/client';
import { encodeFunctionData, erc20Abi, defineChain } from 'viem';
Expand Down Expand Up @@ -72,7 +70,7 @@ export function PrivyOperatorProvider(props: {
appearance: {
theme: '#0a0a0a',
accentColor: '#00dc5f',
walletList: ['detected_ethereum_wallets'],
walletList: ['detected_ethereum_wallets', 'wallet_connect'],
},
}}
>
Expand Down Expand Up @@ -170,12 +168,6 @@ export function circleX402SigningRequirements(quote: PaidApiQuote) {
};
}

function isPrivyEthereumWallet(
value: BaseConnectedWalletType | undefined,
): value is ConnectedWallet {
return value?.type === 'ethereum' && value.walletClientType === 'privy';
}

function validateAddress(value: string, label: string): asserts value is `0x${string}` {
if (!EVM_ADDRESS.test(value)) throw new Error(`${label} is invalid`);
}
Expand Down Expand Up @@ -213,25 +205,14 @@ async function waitForSuccessfulReceipt(provider: EthereumProvider, transactionH

export function usePrivyUserWallet(): UserWalletSession {
const { user } = usePrivy();
const { ready: walletsReady, wallets } = useWallets();
const { wallet: activeWallet, setActiveWallet, connect: connectWallet } = useActiveWallet();
const { wallet: activeWallet, connect: connectWallet } = useActiveWallet();
const explicitlyConnectedWallet = useRef<{
readonly subject: string | null;
readonly wallet: EthereumWallet;
} | null>(null);
const subject = user?.id ?? null;
const selectedWallet: ConnectedWallet | undefined =
walletsReady && isPrivyEthereumWallet(activeWallet)
? activeWallet
: walletsReady
? wallets.find((candidate) => isPrivyEthereumWallet(candidate))
: undefined;

useEffect(() => {
if (selectedWallet && !isPrivyEthereumWallet(activeWallet)) {
setActiveWallet(selectedWallet);
}
}, [activeWallet, selectedWallet, setActiveWallet]);
const selectedWallet: EthereumWallet | undefined =
activeWallet?.type === 'ethereum' ? activeWallet : undefined;

async function selectWallet(): Promise<EthereumWallet | undefined> {
if (selectedWallet) return selectedWallet;
Expand Down Expand Up @@ -498,7 +479,15 @@ export function usePrivyUserWallet(): UserWalletSession {
current!.address,
JSON.stringify({
domain: parameters.domain,
types: parameters.types,
types: {
...parameters.types,
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
},
primaryType: parameters.primaryType,
message: jsonSafe(parameters.message),
}),
Expand Down
38 changes: 24 additions & 14 deletions apps/web/test/privy-session.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ describe('usePrivyOperatorSession — native Privy login', () => {
});
});

describe('usePrivyUserWallet — embedded Privy payer', () => {
describe('usePrivyUserWallet — selected payer', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.active.wallet = undefined;
Expand All @@ -328,18 +328,21 @@ describe('usePrivyUserWallet — embedded Privy payer', () => {
embeddedWallets: { ethereum: { createOnLogin: 'off' } },
defaultChain: { id: 5042002 },
supportedChains: [{ id: 5042002 }],
appearance: {
walletList: ['detected_ethereum_wallets', 'wallet_connect'],
},
});
});

it('signs x402 with the existing Privy wallet when MetaMask is active', async () => {
it('signs x402 with the active MetaMask wallet', async () => {
const metamask = ethereumWallet('metamask', '0x1111111111111111111111111111111111111111');
const privy = ethereumWallet('privy', '0x2222222222222222222222222222222222222222');
mocks.active.wallet = metamask.wallet;
mocks.wallets = [metamask.wallet, privy.wallet];

const { result } = renderHook(() => usePrivyUserWallet());
expect(result.current.address).toBe(privy.wallet.address);
expect(mocks.active.setActiveWallet).toHaveBeenCalledWith(privy.wallet);
expect(result.current.address).toBe(metamask.wallet.address);
expect(mocks.active.setActiveWallet).not.toHaveBeenCalled();

await result.current.signX402Payment({
supplier_id: 'circle-x402-v1',
Expand All @@ -352,10 +355,24 @@ describe('usePrivyUserWallet — embedded Privy payer', () => {
max_timeout_seconds: 300,
});

expect(privy.request).toHaveBeenCalledWith(
expect(metamask.request).toHaveBeenCalledWith(
expect.objectContaining({ method: 'eth_signTypedData_v4' }),
);
expect(metamask.request).not.toHaveBeenCalled();
expect(privy.request).not.toHaveBeenCalled();

const signingRequest = metamask.request.mock.calls.find(
([request]) => request.method === 'eth_signTypedData_v4',
)?.[0] as { params: [string, string] } | undefined;
const typedData = JSON.parse(signingRequest?.params[1] ?? '{}') as {
types?: Record<string, unknown>;
};
expect(typedData.types?.EIP712Domain).toEqual([
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
]);
expect(typedData.types?.TransferWithAuthorization).toBeDefined();
});

it('honors the embedded wallet selected in Privy', async () => {
Expand All @@ -379,11 +396,8 @@ describe('usePrivyUserWallet — embedded Privy payer', () => {
expect(first.request).not.toHaveBeenCalled();
});

it('opens the Privy wallet picker instead of silently using active MetaMask', async () => {
const metamask = ethereumWallet('metamask', '0x1111111111111111111111111111111111111111');
it('opens the Privy wallet picker when no wallet is active', async () => {
const selected = ethereumWallet('rainbow', '0x2222222222222222222222222222222222222222');
mocks.active.wallet = metamask.wallet;
mocks.wallets = [metamask.wallet];
mocks.active.connect.mockResolvedValue({ wallet: selected.wallet, network: 'ethereum' });

const { result } = renderHook(() => usePrivyUserWallet());
Expand Down Expand Up @@ -415,17 +429,13 @@ describe('usePrivyUserWallet — embedded Privy payer', () => {
expect(selected.request).toHaveBeenCalledWith(
expect.objectContaining({ method: 'eth_sendTransaction' }),
);
expect(metamask.request).not.toHaveBeenCalled();
});

it('forgets a picker wallet when the signed-in Privy user changes', async () => {
const metamask = ethereumWallet('metamask', '0x1111111111111111111111111111111111111111');
const first = ethereumWallet('rainbow', '0x2222222222222222222222222222222222222222');
const second = ethereumWallet('coinbase_wallet', '0x3333333333333333333333333333333333333333');
mocks.authenticated = true;
mocks.user = { id: 'did:privy:first' };
mocks.active.wallet = metamask.wallet;
mocks.wallets = [metamask.wallet];
mocks.active.connect.mockResolvedValueOnce({ wallet: first.wallet, network: 'ethereum' });

const { result, rerender } = renderHook(() => usePrivyUserWallet());
Expand Down
Loading