diff --git a/.gitignore b/.gitignore index 7519dcf..82edbe2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ # AI .claude/ +# Playwright +**/playwright-report/ +**/test-results/ +**/.playwright-mcp/ + # Solana Test Validator **/test-ledger .runbook-logs/* diff --git a/.prettierignore b/.prettierignore index 3b8779f..eba6e22 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,9 @@ clients/typescript/src/generated/ dist/ build/ target/ +**/.next/ +**/playwright-report/ +**/test-results/ # Dependencies node_modules/ diff --git a/apps/web/e2e/escrow-ui.spec.ts b/apps/web/e2e/escrow-ui.spec.ts new file mode 100644 index 0000000..678cd42 --- /dev/null +++ b/apps/web/e2e/escrow-ui.spec.ts @@ -0,0 +1,327 @@ +/** + * E2E tests for the Escrow Program devnet UI. + * + * Tests run serially and share on-chain state (a single escrow created in the first test). + * Set PLAYRIGHT_WALLET (base58 secret key) and optionally APP_URL in .env at repo root. + * + * Known on-chain failures are assertions too — they verify the UI surfaces the right error. + */ +import { expect, type Page, test } from '@playwright/test'; + +import { connectWallet, injectWallet } from './helpers/wallet'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DEVNET_USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; +const SYSTEM_PROGRAM = '11111111111111111111111111111111'; +// Wrapped SOL has no AllowedMint PDA so Block Mint should reject it (tests the error path). +const WRAPPED_SOL = 'So11111111111111111111111111111111111111112'; + +// ─── Shared state (populated by earlier tests) ─────────────────────────────── + +let walletAddress = ''; +let escrowPda = ''; +let receiptPda = ''; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Navigate to a panel via the sidebar. + * `navLabel` is the sidebar button text (may be abbreviated); `headingName` is the h2 on the panel. + * When they match, only one argument is needed. + */ +async function openPanel(page: Page, headingName: string, navLabel?: string): Promise { + await page.getByRole('button', { exact: true, name: navLabel ?? headingName }).click(); + await expect(page.getByRole('heading', { level: 2, name: headingName })).toBeVisible(); +} + +/** Click the single Autofill button on the active panel. */ +async function autofill(page: Page, nth = 0): Promise { + await page.getByRole('button', { name: 'Autofill' }).nth(nth).click(); +} + +/** + * Clicks Send and waits for the transaction to land (success or failure). + * + * Reads the RecentTransactions count BEFORE clicking so fast devnet confirmations + * (< 500ms) don't cause a TOCTOU race. Returns 'success' | 'failed'. + */ +async function sendAndWait(page: Page): Promise<'failed' | 'success'> { + const heading = page.getByRole('heading', { name: /Recent Transactions/ }); + + // Snapshot count BEFORE clicking send — must happen first to avoid races. + // The heading is only rendered once there is ≥1 transaction, so default to 0. + const beforeText = (await heading.textContent({ timeout: 500 }).catch(() => '')) ?? ''; + const beforeCount = parseInt(beforeText.match(/\d+/)?.[0] ?? '0'); + + await page.getByRole('button', { name: 'Send Transaction' }).click(); + + // Wait until a new entry appears (count increases by 1). + await expect(async () => { + const text = (await heading.textContent()) ?? ''; + const count = parseInt(text.match(/\d+/)?.[0] ?? '0'); + expect(count).toBeGreaterThan(beforeCount); + }).toPass({ intervals: [500, 1000, 2000], timeout: 45_000 }); + + if (await page.getByText('Success', { exact: true }).last().isVisible()) return 'success'; + return 'failed'; +} + +// ─── Suite setup ───────────────────────────────────────────────────────────── + +test.describe('Escrow Program UI', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + + test.beforeAll(async ({ browser }) => { + const walletKey = process.env.PLAYRIGHT_WALLET; + if (!walletKey) throw new Error('PLAYRIGHT_WALLET env var is not set'); + + page = await browser.newPage(); + await page.goto('/'); + walletAddress = await injectWallet(page, walletKey); + await connectWallet(page); + }); + + test.afterAll(async () => { + await page.close(); + }); + + // ─── Instruction: Create Escrow ────────────────────────────────────────── + + test('Create Escrow — succeeds and saves PDA to QuickDefaults', async () => { + await openPanel(page, 'Create Escrow'); + await expect(page.getByRole('textbox', { name: 'Admin Address' })).toHaveValue(walletAddress); + + const result = await sendAndWait(page); + expect(result).toBe('success'); + + // The escrow PDA is saved automatically to the QuickDefaults combobox. + const defaultEscrow = page.getByRole('combobox', { name: 'Default Escrow' }); + await expect(defaultEscrow).not.toHaveValue(''); + escrowPda = await defaultEscrow.inputValue(); + expect(escrowPda.length).toBeGreaterThanOrEqual(32); + expect(escrowPda.length).toBeLessThanOrEqual(44); + + await expect(page.locator('text=1 saved').first()).toBeVisible(); + }); + + // ─── Instruction: Allow Mint ───────────────────────────────────────────── + + test('Allow Mint — succeeds for devnet USDC', async () => { + await openPanel(page, 'Allow Mint'); + await autofill(page, 0); // Escrow + await page.getByRole('textbox', { name: 'Mint Address' }).fill(DEVNET_USDC_MINT); + + expect(await sendAndWait(page)).toBe('success'); + + // Mint is saved to QuickDefaults after success. + await expect(page.getByRole('combobox', { name: 'Default Mint' })).toHaveValue(DEVNET_USDC_MINT); + }); + + // ─── Instruction: Deposit ──────────────────────────────────────────────── + + test('Deposit — succeeds and saves receipt PDA to QuickDefaults', async () => { + await openPanel(page, 'Deposit'); + await autofill(page, 0); // Escrow + await autofill(page, 1); // Mint + await page.getByRole('spinbutton', { name: 'Amount (in base units)' }).fill('100'); + + expect(await sendAndWait(page)).toBe('success'); + + const defaultReceipt = page.getByRole('combobox', { name: 'Default Receipt' }); + await expect(defaultReceipt).not.toHaveValue(''); + receiptPda = await defaultReceipt.inputValue(); + expect(receiptPda.length).toBeGreaterThanOrEqual(32); + expect(receiptPda.length).toBeLessThanOrEqual(44); + }); + + // ─── Instruction: Add Timelock ─────────────────────────────────────────── + + test('Add Timelock — succeeds with 1s duration', async () => { + await openPanel(page, 'Add Timelock'); + await autofill(page); + await page.getByRole('spinbutton', { name: 'Lock Duration (seconds)' }).fill('1'); + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Instruction: Set Arbiter ──────────────────────────────────────────── + + test('Set Arbiter — succeeds (connected wallet as arbiter)', async () => { + await openPanel(page, 'Set Arbiter'); + await autofill(page); + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Instruction: Withdraw ─────────────────────────────────────────────── + // Runs before Set Hook so only the arbiter extension is active. + // The arbiter (connected wallet) is auto-detected from the extensions PDA. + + test('Withdraw — succeeds (arbiter auto-detected and signed automatically)', async () => { + await openPanel(page, 'Withdraw'); + await autofill(page, 0); // Escrow + await autofill(page, 1); // Mint + await autofill(page, 2); // Receipt + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Instruction: Set Hook ─────────────────────────────────────────────── + + test('Set Hook — succeeds with System Program as hook address', async () => { + await openPanel(page, 'Set Hook'); + await autofill(page); + await page.getByRole('textbox', { name: 'Hook Program Address' }).fill(SYSTEM_PROGRAM); + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Instruction: Block Token Extension ───────────────────────────────── + + test('Block Token Extension — succeeds for NonTransferable (type 5)', async () => { + await openPanel(page, 'Block Token Extension', 'Block Token Ext'); + await autofill(page); + // Extension Type defaults to 5 (NonTransferable); no change needed. + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Instruction: Block Mint ───────────────────────────────────────────── + + test('Block Mint — fails for mint that was never allowed (wSOL)', async () => { + await openPanel(page, 'Block Mint'); + await autofill(page, 0); // Escrow + await page.getByRole('textbox', { name: 'Mint Address' }).fill(WRAPPED_SOL); + + expect(await sendAndWait(page)).toBe('failed'); + await expect(page.getByText('Transaction failed').last()).toBeVisible(); + }); + + test('Block Mint — succeeds for the previously allowed mint', async () => { + await openPanel(page, 'Block Mint'); + await autofill(page, 0); // Escrow + await autofill(page, 1); // Mint (autofills devnet USDC from QuickDefaults) + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Instruction: Update Admin ─────────────────────────────────────────── + + test('Update Admin — succeeds (idempotent, keeps same admin)', async () => { + await openPanel(page, 'Update Admin'); + await autofill(page); + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Client-side validation ────────────────────────────────────────────── + + test.describe('Client-side validation', () => { + test.beforeEach(async () => { + await openPanel(page, 'Deposit'); + }); + + test('empty required field — browser native validation blocks submit', async () => { + // All fields blank; clicking Send should NOT trigger a network request. + // The browser focuses the first empty required input instead. + const txCountBefore = await page.getByRole('heading', { name: /Recent Transactions/ }).textContent(); + + await page.getByRole('button', { name: 'Send Transaction' }).click(); + + // Transaction count must not change. + await expect(page.getByRole('heading', { name: /Recent Transactions/ })).toHaveText(txCountBefore!); + + // Escrow field should be focused (browser scrolled to it). + await expect(page.getByRole('textbox', { name: 'Escrow Address' })).toBeFocused(); + }); + + test('invalid address — shows validation error without submitting', async () => { + await page.getByRole('textbox', { name: 'Escrow Address' }).fill('notanaddress'); + await page.getByRole('textbox', { name: 'Mint Address' }).fill(DEVNET_USDC_MINT); + await page.getByRole('spinbutton', { name: 'Amount' }).fill('100'); + + await page.getByRole('button', { name: 'Send Transaction' }).click(); + + await expect(page.getByText('Escrow address is not a valid Solana address.')).toBeVisible(); + }); + + test('zero amount — shows "Amount must be greater than 0"', async () => { + await page.getByRole('textbox', { name: 'Escrow Address' }).fill(escrowPda); + await page.getByRole('textbox', { name: 'Mint Address' }).fill(DEVNET_USDC_MINT); + await page.getByRole('spinbutton', { name: 'Amount' }).fill('0'); + + await page.getByRole('button', { name: 'Send Transaction' }).click(); + + await expect(page.getByText('Amount must be greater than 0.')).toBeVisible(); + }); + + test('negative amount — shows "Amount must be a whole number"', async () => { + await page.getByRole('textbox', { name: 'Escrow Address' }).fill(escrowPda); + await page.getByRole('textbox', { name: 'Mint Address' }).fill(DEVNET_USDC_MINT); + await page.getByRole('spinbutton', { name: 'Amount' }).fill('-1'); + + await page.getByRole('button', { name: 'Send Transaction' }).click(); + + await expect(page.getByText('Amount must be a whole number.')).toBeVisible(); + }); + + test('Withdraw — empty receipt shows validation error', async () => { + await openPanel(page, 'Withdraw'); + await page.getByRole('textbox', { name: 'Escrow Address' }).fill(escrowPda); + await page.getByRole('textbox', { name: 'Mint Address' }).fill(DEVNET_USDC_MINT); + // Leave Receipt blank. + + await page.getByRole('button', { name: 'Send Transaction' }).click(); + + // Browser native required validation focuses the receipt field. + await expect(page.getByRole('textbox', { name: 'Receipt Address' })).toBeFocused(); + }); + }); + + // ─── UI components ─────────────────────────────────────────────────────── + + test.describe('UI components', () => { + test('RPC badge opens dropdown with network presets and custom URL input', async () => { + await page.getByRole('button', { name: /Devnet/ }).click(); + await expect(page.getByRole('button', { name: /Mainnet/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Testnet/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Localhost/i })).toBeVisible(); + await expect(page.getByRole('textbox', { name: /my-rpc/i })).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + test('Program badge shows editable program ID', async () => { + await page.getByRole('button', { name: /Program:/ }).click(); + await expect(page.getByRole('textbox', { name: /Escrowae7/ })).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + test('QuickDefaults Clear removes all saved values', async () => { + // Ensure at least escrow is saved from earlier tests. + await expect(page.getByRole('combobox', { name: 'Default Escrow' })).not.toHaveValue(''); + + await page.getByRole('button', { name: 'Clear Saved' }).click(); + + await expect(page.getByRole('combobox', { name: 'Default Escrow' })).toHaveValue(''); + await expect(page.getByRole('combobox', { name: 'Default Mint' })).toHaveValue(''); + await expect(page.getByRole('combobox', { name: 'Default Receipt' })).toHaveValue(''); + await expect(page.getByText('0 saved').first()).toBeVisible(); + }); + + test('RecentTransactions shows all successful txs with View Explorer links', async () => { + // At this point we should have multiple successful transactions. + const heading = page.getByRole('heading', { name: /Recent Transactions \(\d+\)/ }); + await expect(heading).toBeVisible(); + + const count = parseInt((await heading.textContent())!.match(/\d+/)![0]); + expect(count).toBeGreaterThanOrEqual(6); // Create, AllowMint, Deposit, Timelock, SetHook, BlockTokenExt, BlockMint, UpdateAdmin + + // Every successful entry should have a View Explorer button. + const explorerButtons = page.getByRole('button', { name: 'View Explorer' }); + await expect(explorerButtons.first()).toBeVisible(); + }); + }); +}); diff --git a/apps/web/e2e/helpers/wallet.ts b/apps/web/e2e/helpers/wallet.ts new file mode 100644 index 0000000..c47d016 --- /dev/null +++ b/apps/web/e2e/helpers/wallet.ts @@ -0,0 +1,164 @@ +import type { Page } from '@playwright/test'; + +/** + * Injects a mock Phantom wallet into the page using TweetNaCl for Ed25519 signing. + * + * Must be called after page.goto() but before clicking "Select Wallet". + * After calling this, call connectWallet() to trigger the adapter connect flow. + * + * Returns the wallet's base58 public key. + */ +export async function injectWallet(page: Page, walletKeyBase58: string): Promise { + await page.evaluate(key => { + (window as any)._walletKey = key; + }, walletKeyBase58); + + await page.evaluate( + () => + new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js'; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load TweetNaCl')); + document.head.appendChild(script); + }), + ); + + // Minimal Buffer polyfill — the Phantom wallet adapter uses Buffer.from() internally. + await page.evaluate(() => { + (window as any).Buffer = { + alloc: (size: number, fill = 0) => new Uint8Array(size).fill(fill), + concat: (bufs: Uint8Array[]) => { + const total = bufs.reduce((s, b) => s + b.length, 0); + const result = new Uint8Array(total); + let offset = 0; + for (const b of bufs) { + result.set(b, offset); + offset += b.length; + } + return result; + }, + from: (data: any) => { + if (data instanceof Uint8Array) return data; + if (Array.isArray(data)) return new Uint8Array(data); + return new Uint8Array(data); + }, + isBuffer: (obj: any) => obj instanceof Uint8Array, + }; + }); + + const pubkey = await page.evaluate((walletKey: string) => { + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + + function b58Decode(s: string): Uint8Array { + const bytes = [0]; + for (const c of s) { + const idx = ALPHABET.indexOf(c); + if (idx < 0) throw new Error('Invalid base58 char: ' + c); + let carry = idx; + for (let j = 0; j < bytes.length; j++) { + carry += bytes[j] * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + for (const c of s) { + if (c === '1') bytes.push(0); + else break; + } + return new Uint8Array(bytes.reverse()); + } + + function b58Encode(bytes: Uint8Array): string { + const digits = [0]; + for (let i = 0; i < bytes.length; i++) { + let carry = bytes[i]; + for (let j = 0; j < digits.length; j++) { + carry += digits[j] * 256; + digits[j] = carry % 58; + carry = Math.floor(carry / 58); + } + while (carry > 0) { + digits.push(carry % 58); + carry = Math.floor(carry / 58); + } + } + let result = ''; + for (let i = 0; i < bytes.length - 1 && bytes[i] === 0; i++) result += '1'; + return ( + result + + digits + .reverse() + .map(d => ALPHABET[d]) + .join('') + ); + } + + const nacl = (window as any).nacl; + const kp = nacl.sign.keyPair.fromSecretKey(b58Decode(walletKey)); + const pubkeyB58 = b58Encode(kp.publicKey); + + (window as any)._kp = kp; + (window as any)._pubkey = pubkeyB58; + + (window as any).solana = { + _events: {} as Record void)[]>, + connect: async () => ({ publicKey: (window as any).solana.publicKey }), + disconnect: async () => {}, + emit(event: string, ...args: any[]) { + (this._events[event] ?? []).forEach((h: any) => h(...args)); + }, + isConnected: true, + isPhantom: true, + off(event: string, handler: (...args: any[]) => void) { + if (this._events[event]) { + this._events[event] = this._events[event].filter((h: any) => h !== handler); + } + }, + on(event: string, handler: (...args: any[]) => void) { + if (!this._events[event]) this._events[event] = []; + this._events[event].push(handler); + }, + publicKey: { + toBase58: () => pubkeyB58, + toBytes: () => kp.publicKey, + toString: () => pubkeyB58, + }, + removeListener(event: string, handler: (...args: any[]) => void) { + this.off(event, handler); + }, + signAllTransactions: async (txs: any[]) => + await Promise.all(txs.map((tx: any) => (window as any).solana.signTransaction(tx))), + signMessage: async (msg: Uint8Array) => ({ + signature: new Uint8Array(nacl.sign.detached(msg, kp.secretKey)), + }), + signTransaction: async (tx: any) => { + const msgBytes = new Uint8Array(tx.message.serialize()); + const sig = nacl.sign.detached(msgBytes, kp.secretKey); + tx.signatures[0] = new Uint8Array(sig); + return tx; + }, + }; + + return pubkeyB58; + }, walletKeyBase58); + + return pubkey; +} + +/** + * Opens the wallet modal and selects "Phantom Detected". + * + * Must be called after injectWallet(). The adapter captures window.solana.signTransaction + * at connect time, so this must happen after injection — not before. + */ +export async function connectWallet(page: Page): Promise { + const connectBtn = page.getByRole('button', { name: /Select Wallet|Connect Wallet/ }); + await connectBtn.click(); + await page.getByRole('button', { name: /Phantom.*Detected/i }).click(); + await page.getByRole('button', { name: /Disconnect/i }).waitFor({ timeout: 8000 }); +} diff --git a/apps/web/package.json b/apps/web/package.json index a05af8a..3c54fed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,9 @@ "build": "next build", "dev": "next dev", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@base-ui/react": "^1.1.0", @@ -32,7 +34,9 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "dotenv": "^16.4.7", "tailwindcss": "^4.2.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "@playwright/test": "^1.50.0" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..00e5ea7 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +export default defineConfig({ + projects: [ + { + name: 'chromium', + use: { channel: 'chromium' }, + }, + ], + reporter: [['list'], ['html', { open: 'never' }]], + retries: 0, + testDir: './e2e', + timeout: 60_000, + use: { + baseURL: process.env.APP_URL ?? 'https://solana-escrow-program.vercel.app/', + headless: true, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + workers: 1, +}); diff --git a/apps/web/src/components/instructions/Withdraw.tsx b/apps/web/src/components/instructions/Withdraw.tsx index 52e269f..01f35f0 100644 --- a/apps/web/src/components/instructions/Withdraw.tsx +++ b/apps/web/src/components/instructions/Withdraw.tsx @@ -1,22 +1,57 @@ 'use client'; import { useState } from 'react'; -import type { Address } from '@solana/kit'; -import { getWithdrawInstructionAsync } from '@solana/escrow-program-client'; +import { AccountRole, type Address, fetchEncodedAccount, createSolanaRpc, getAddressDecoder } from '@solana/kit'; +import { findExtensionsPda, getWithdrawInstructionAsync } from '@solana/escrow-program-client'; import { useSendTx } from '@/hooks/useSendTx'; import { useSavedValues } from '@/contexts/SavedValuesContext'; import { useWallet } from '@/contexts/WalletContext'; import { useProgramContext } from '@/contexts/ProgramContext'; +import { useRpcContext } from '@/contexts/RpcContext'; import { TxResult } from '@/components/TxResult'; import { firstValidationError, validateAddress, validateOptionalAddress } from '@/lib/validation'; import { FormField, SendButton } from './shared'; +// TLV layout: [discriminator(1), version(1), bump(1), extensionCount(1), ...entries] +// Each entry: [type(u16-LE), length(u16-LE), data(length bytes)] +const HEADER_SIZE = 4; +const ENTRY_HEADER_SIZE = 4; +const HOOK_TYPE = 1; +const ARBITER_TYPE = 3; + +function parseExtensions(data: Uint8Array): { arbiter: Address | null; hookProgram: Address | null } { + let arbiter: Address | null = null; + let hookProgram: Address | null = null; + + const decoder = getAddressDecoder(); + let offset = HEADER_SIZE; + + while (offset + ENTRY_HEADER_SIZE <= data.length) { + const type = data[offset] | (data[offset + 1] << 8); + const length = data[offset + 2] | (data[offset + 3] << 8); + const start = offset + ENTRY_HEADER_SIZE; + const end = start + length; + if (end > data.length) break; + + if (type === ARBITER_TYPE && length >= 32) { + arbiter = decoder.decode(data.slice(start, start + 32)); + } else if (type === HOOK_TYPE && length >= 32) { + hookProgram = decoder.decode(data.slice(start, start + 32)); + } + + offset = end; + } + + return { arbiter, hookProgram }; +} + export function Withdraw() { const { account, createSigner } = useWallet(); const { send, sending, signature, error, reset } = useSendTx(); const { defaultEscrow, defaultMint, defaultReceipt, rememberEscrow, rememberMint, rememberReceipt } = useSavedValues(); const { programId } = useProgramContext(); + const { rpcUrl } = useRpcContext(); const [escrow, setEscrow] = useState(''); const [mint, setMint] = useState(''); const [receipt, setReceipt] = useState(''); @@ -41,6 +76,31 @@ export function Withdraw() { return; } + // Auto-detect arbiter + hook from the extensions PDA and append as remaining accounts. + const [extensionsPda] = await findExtensionsPda( + { escrow: escrow as Address }, + { programAddress: programId as Address }, + ); + const rpc = createSolanaRpc(rpcUrl); + const extensionsAccount = await fetchEncodedAccount(rpc, extensionsPda); + + const remainingAccounts: object[] = []; + if (extensionsAccount.exists) { + const { arbiter, hookProgram } = parseExtensions(new Uint8Array(extensionsAccount.data)); + // Arbiter must be first and must sign. + // If the arbiter is the connected wallet, attach the signer so @solana/kit's + // signTransactionMessageWithSigners knows to call it. + if (arbiter) { + remainingAccounts.push( + arbiter === (signer.address as string) + ? { address: arbiter, role: AccountRole.READONLY_SIGNER, signer } + : { address: arbiter, role: AccountRole.READONLY_SIGNER }, + ); + } + // Hook program comes after arbiter (the processor slices past it before invoking the hook). + if (hookProgram) remainingAccounts.push({ address: hookProgram, role: AccountRole.READONLY }); + } + const ix = await getWithdrawInstructionAsync( { withdrawer: signer, @@ -51,7 +111,12 @@ export function Withdraw() { }, { programAddress: programId as Address }, ); - const txSignature = await send([ix], { + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const finalIx: any = + remainingAccounts.length > 0 ? { ...ix, accounts: [...ix.accounts, ...remainingAccounts] } : ix; + + const txSignature = await send([finalIx], { action: 'Withdraw', values: { escrow, mint, receipt, rentRecipient: rentRecipient || account?.address || '' }, }); diff --git a/eslint.config.mjs b/eslint.config.mjs index d0d4700..f000846 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,10 @@ export default [ '**/target/**', '**/generated/**', 'clients/typescript/src/generated/**', + '**/.next/**', + '**/e2e/**', + '**/playwright-report/**', + '**/test-results/**', 'eslint.config.mjs', '**/*.mjs', ], diff --git a/justfile b/justfile index beaf7a3..399f06b 100644 --- a/justfile +++ b/justfile @@ -54,6 +54,14 @@ integration-test *args: # Run all tests (use --with-cu to track compute units) test *args: build unit-test (integration-test args) +# Deploy the web UI to Vercel production +deploy-web: + vercel deploy --prod + +# Run E2E tests against the live devnet UI (requires PLAYRIGHT_WALLET in .env) +e2e-test: + pnpm --filter @solana/escrow-program-web test:e2e + # Build Client for Examples build-client: pnpm run generate-clients diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fccb4a..d4e228d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,7 +145,7 @@ importers: version: 12.36.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: ^16.1.6 - version: 16.1.6(@babel/core@7.28.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.6(@babel/core@7.28.5)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -153,6 +153,9 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) devDependencies: + '@playwright/test': + specifier: ^1.50.0 + version: 1.58.2 '@tailwindcss/postcss': specifier: ^4.2.1 version: 4.2.1 @@ -165,6 +168,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + dotenv: + specifier: ^16.4.7 + version: 16.6.1 tailwindcss: specifier: ^4.2.1 version: 4.2.1 @@ -1269,6 +1275,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@project-serum/sol-wallet-adapter@0.2.6': resolution: {integrity: sha512-cpIb13aWPW8y4KzkZAPDgw+Kb+DXjCC6rZoH74MGm3I/6e/zKyGnfAuW5olb2zxonFqsYgnv7ev8MQnvSgJ3/g==} engines: {node: '>=10'} @@ -4661,6 +4672,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + draggabilly@3.0.0: resolution: {integrity: sha512-aEs+B6prbMZQMxc9lgTpCBfyCUhRur/VFucHhIOvlvvdARTj7TcDmX/cdOUtqbjJJUh7+agyJXR5Z6IFe1MxwQ==} @@ -5058,6 +5073,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6270,6 +6290,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -8572,6 +8602,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@project-serum/sol-wallet-adapter@0.2.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))': dependencies: '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) @@ -13517,6 +13551,8 @@ snapshots: dependencies: path-type: 4.0.0 + dotenv@16.6.1: {} + draggabilly@3.0.0: dependencies: get-size: 3.0.0 @@ -13963,6 +13999,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -15276,7 +15315,7 @@ snapshots: neo-async@2.6.2: {} - next@16.1.6(@babel/core@7.28.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.1.6(@babel/core@7.28.5)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -15295,6 +15334,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.1.6 '@next/swc-win32-arm64-msvc': 16.1.6 '@next/swc-win32-x64-msvc': 16.1.6 + '@playwright/test': 1.58.2 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -15529,6 +15569,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} possible-typed-array-names@1.1.0: {}