diff --git a/.changeset/green-gorillas-clean.md b/.changeset/green-gorillas-clean.md new file mode 100644 index 0000000..af0bd9b --- /dev/null +++ b/.changeset/green-gorillas-clean.md @@ -0,0 +1,5 @@ +--- +"@abstract-foundation/mpp": patch +--- + +Add onChannelOpened callback to abstractSession client options, called after the on-chain channel open but before voucher signing — supports deferring the voucher via a returned Promise diff --git a/examples/mpp-demo/src/components/demo/session-demo.tsx b/examples/mpp-demo/src/components/demo/session-demo.tsx index 5dd8355..5760191 100644 --- a/examples/mpp-demo/src/components/demo/session-demo.tsx +++ b/examples/mpp-demo/src/components/demo/session-demo.tsx @@ -1,13 +1,13 @@ 'use client' import { useState, useRef, useCallback } from 'react' +import { formatUnits } from 'viem' import { useAccount, useWalletClient } from 'wagmi' import { createSessionClient } from '@/lib/mpp-client' -import { SESSION_AMOUNT } from '@/lib/constants' +import { SESSION_AMOUNT, SESSION_DEPOSIT, USDC_DECIMALS } from '@/lib/constants' import type { Account, Transport, WalletClient } from 'viem' import type { ChainEIP712 } from 'viem/zksync' - interface SessionResponse { message: string timestamp: string @@ -23,11 +23,18 @@ interface SessionState { responses: SessionResponse[] } +type Phase = 'idle' | 'opening' | 'awaitingVoucher' | 'signing' + +function formatCumulative(raw: string): string { + if (raw === '0') return '$0' + return `$${formatUnits(BigInt(raw), USDC_DECIMALS)}` +} + export function SessionDemo() { const { address } = useAccount() const { data: walletClient } = useWalletClient() - const [loading, setLoading] = useState(false) + const [phase, setPhase] = useState('idle') const [state, setState] = useState({ requestCount: 0, cumulativeAmount: '0', @@ -38,6 +45,15 @@ export function SessionDemo() { const mppxRef = useRef | null>(null) const walletRef = useRef(null) + const voucherResolverRef = useRef<(() => void) | null>(null) + const fetchPromiseRef = useRef | null>(null) + + const onChannelOpened = useCallback(async () => { + return new Promise((resolve) => { + voucherResolverRef.current = resolve + setPhase('awaitingVoucher') + }) + }, []) const getOrCreateClient = useCallback(() => { if (!walletClient?.account) return null @@ -46,69 +62,115 @@ export function SessionDemo() { if (walletRef.current !== walletKey) { mppxRef.current = createSessionClient( walletClient as WalletClient, + { onChannelOpened }, ) walletRef.current = walletKey } return mppxRef.current - }, [walletClient]) + }, [walletClient, onChannelOpened]) - const handleSessionRequest = async () => { + const processResponse = useCallback(async (response: Response) => { + if (!response.ok) { + const text = await response.text() + throw new Error( + `Request failed (${response.status}): ${text.slice(0, 200)}`, + ) + } + + const data = (await response.json()) as SessionResponse + const receipt = response.headers.get('Payment-Receipt') + + let receiptCumulative: string | undefined + if (receipt) { + try { + const payload = receipt.startsWith('Payment ') + ? receipt.slice('Payment '.length) + : receipt + const decoded = atob( + payload.replace(/-/g, '+').replace(/_/g, '/'), + ) + const parsed = JSON.parse(decoded) + if (parsed.acceptedCumulative) { + receiptCumulative = parsed.acceptedCumulative + } + } catch { + /* receipt parsing is best-effort */ + } + } + + setState((prev) => ({ + requestCount: prev.requestCount + 1, + cumulativeAmount: receiptCumulative ?? prev.cumulativeAmount, + channelOpen: true, + responses: [data, ...prev.responses].slice(0, 5), + })) + }, []) + + const handleOpenChannel = async () => { const mppx = getOrCreateClient() if (!mppx) return - setLoading(true) + setPhase('opening') setError(null) + const fetchPromise = mppx.fetch('/api/session') + fetchPromiseRef.current = fetchPromise + + fetchPromise.catch((err) => { + if (voucherResolverRef.current) return + setPhase('idle') + setError( + err instanceof Error ? err.message.split('\n')[0] : 'Unknown error', + ) + fetchPromiseRef.current = null + }) + } + + const handleSignVoucher = async () => { + setPhase('signing') + voucherResolverRef.current?.() + voucherResolverRef.current = null + try { - const response = await mppx.fetch('/api/session') + const response = await fetchPromiseRef.current! + await processResponse(response) + } catch (err) { + setError( + err instanceof Error ? err.message.split('\n')[0] : 'Unknown error', + ) + } finally { + setPhase('idle') + fetchPromiseRef.current = null + } + } - if (!response.ok) { - const text = await response.text() - throw new Error( - `Request failed (${response.status}): ${text.slice(0, 200)}`, - ) - } + const handleSessionRequest = async () => { + const mppx = getOrCreateClient() + if (!mppx) return - const data = (await response.json()) as SessionResponse - const receipt = response.headers.get('Payment-Receipt') - - let cumulativeAmount = state.cumulativeAmount - if (receipt) { - try { - const payload = receipt.startsWith('Payment ') - ? receipt.slice('Payment '.length) - : receipt - const decoded = atob( - payload.replace(/-/g, '+').replace(/_/g, '/'), - ) - const parsed = JSON.parse(decoded) - if (parsed.acceptedCumulative) { - cumulativeAmount = parsed.acceptedCumulative - } - } catch { - /* receipt parsing is best-effort */ - } - } + setPhase('signing') + setError(null) - setState((prev) => ({ - requestCount: prev.requestCount + 1, - cumulativeAmount, - channelOpen: true, - responses: [data, ...prev.responses].slice(0, 5), - })) + try { + const response = await mppx.fetch('/api/session') + await processResponse(response) } catch (err) { setError( err instanceof Error ? err.message.split('\n')[0] : 'Unknown error', ) } finally { - setLoading(false) + setPhase('idle') } } const handleReset = () => { + voucherResolverRef.current?.() + voucherResolverRef.current = null + fetchPromiseRef.current = null mppxRef.current = null walletRef.current = null + setPhase('idle') setState({ requestCount: 0, cumulativeAmount: '0', @@ -118,6 +180,11 @@ export function SessionDemo() { setError(null) } + const busy = phase !== 'idle' && phase !== 'awaitingVoucher' + const btnBase = + 'flex-1 rounded-lg border transition-colors flex items-center justify-center gap-2 cursor-pointer text-sm px-5 h-10 font-sans disabled:opacity-40 disabled:cursor-not-allowed' + const btnAccent = `${btnBase} border-accent/30 bg-accent/10 text-accent hover:bg-accent/20` + return (

@@ -133,31 +200,55 @@ export function SessionDemo() {

Cumulative -

{state.cumulativeAmount}

+

+ {formatCumulative(state.cumulativeAmount)} +

)}
- + {phase === 'awaitingVoucher' ? ( + + ) : state.channelOpen ? ( + + ) : ( + + )} {state.channelOpen && (