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/next-env.d.ts b/apps/web/next-env.d.ts index 1511519..20e7bcf 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/types/routes.d.ts'; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index a05af8a..fb47649 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", @@ -21,6 +23,7 @@ "@solana/wallet-adapter-react-ui": "^0.9.39", "@solana/wallet-adapter-wallets": "^0.19.37", "@solana/web3.js": "^1.98.4", + "@vercel/analytics": "^2.0.1", "clsx": "^2.1.1", "motion": "^12.26.0", "next": "^16.1.6", @@ -28,10 +31,12 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@playwright/test": "^1.50.0", "@tailwindcss/postcss": "^4.2.1", "@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" } 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/app/layout.tsx b/apps/web/src/app/layout.tsx index 8fcf0ef..f88c26a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import './globals.css'; import '@solana/wallet-adapter-react-ui/styles.css'; import { Providers } from '@/components/Providers'; +import { Analytics } from '@vercel/analytics/next'; export const metadata: Metadata = { title: 'Escrow Program', @@ -13,6 +14,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + ); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b2d72c9..bb17ef1 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -9,6 +9,7 @@ import { QuickDefaults } from '@/components/QuickDefaults'; import { RecentTransactions } from '@/components/RecentTransactions'; import { CreateEscrow } from '@/components/instructions/CreateEscrow'; import { UpdateAdmin } from '@/components/instructions/UpdateAdmin'; +import { SetImmutable } from '@/components/instructions/SetImmutable'; import { AllowMint } from '@/components/instructions/AllowMint'; import { BlockMint } from '@/components/instructions/BlockMint'; import { AddTimelock } from '@/components/instructions/AddTimelock'; @@ -23,6 +24,7 @@ import { Withdraw } from '@/components/instructions/Withdraw'; type InstructionId = | 'createEscrow' | 'updateAdmin' + | 'setImmutable' | 'allowMint' | 'blockMint' | 'addTimelock' @@ -43,6 +45,7 @@ const NAV: { items: [ { id: 'createEscrow', label: 'Create Escrow' }, { id: 'updateAdmin', label: 'Update Admin' }, + { id: 'setImmutable', label: 'Set Immutable' }, { id: 'allowMint', label: 'Allow Mint' }, { id: 'blockMint', label: 'Block Mint' }, ], @@ -70,6 +73,7 @@ const NAV: { const PANELS: Record = { createEscrow: { title: 'Create Escrow', component: CreateEscrow }, updateAdmin: { title: 'Update Admin', component: UpdateAdmin }, + setImmutable: { title: 'Set Immutable', component: SetImmutable }, allowMint: { title: 'Allow Mint', component: AllowMint }, blockMint: { title: 'Block Mint', component: BlockMint }, addTimelock: { title: 'Add Timelock', component: AddTimelock }, diff --git a/apps/web/src/components/instructions/SetImmutable.tsx b/apps/web/src/components/instructions/SetImmutable.tsx new file mode 100644 index 0000000..2229c9e --- /dev/null +++ b/apps/web/src/components/instructions/SetImmutable.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useState } from 'react'; +import type { Address } from '@solana/kit'; +import { Badge } from '@solana/design-system/badge'; +import { getSetImmutableInstruction } 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 { TxResult } from '@/components/TxResult'; +import { firstValidationError, validateAddress } from '@/lib/validation'; +import { FormField, SendButton } from './shared'; + +export function SetImmutable() { + const { createSigner } = useWallet(); + const { send, sending, signature, error, reset } = useSendTx(); + const { defaultEscrow, rememberEscrow } = useSavedValues(); + const { programId } = useProgramContext(); + const [escrow, setEscrow] = useState(''); + const [formError, setFormError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + reset(); + setFormError(null); + const signer = createSigner(); + if (!signer) return; + + const validationError = firstValidationError(validateAddress(escrow, 'Escrow address')); + if (validationError) { + setFormError(validationError); + return; + } + + const ix = getSetImmutableInstruction( + { + admin: signer, + escrow: escrow as Address, + }, + { programAddress: programId as Address }, + ); + + const txSignature = await send([ix], { + action: 'Set Immutable', + values: { escrow }, + }); + if (txSignature) { + rememberEscrow(escrow); + } + }; + + return ( +
{ + void handleSubmit(e); + }} + style={{ display: 'flex', flexDirection: 'column', gap: 16 }} + > +
+ + This action is one-way. Escrow configuration becomes permanently immutable. + +
+ + + + + ); +} 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/apps/web/src/lib/transactionErrors.ts b/apps/web/src/lib/transactionErrors.ts index 3f4b595..0c416c2 100644 --- a/apps/web/src/lib/transactionErrors.ts +++ b/apps/web/src/lib/transactionErrors.ts @@ -1,6 +1,7 @@ 'use client'; import { + ESCROW_PROGRAM_ERROR__ESCROW_IMMUTABLE, ESCROW_PROGRAM_ERROR__HOOK_PROGRAM_MISMATCH, ESCROW_PROGRAM_ERROR__HOOK_REJECTED, ESCROW_PROGRAM_ERROR__INVALID_ADMIN, @@ -36,6 +37,7 @@ const ESCROW_PROGRAM_ERROR_MESSAGES: Record = { [ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_NOT_BLOCKED]: 'Token extension is not currently blocked', [ESCROW_PROGRAM_ERROR__ZERO_DEPOSIT_AMOUNT]: 'Zero deposit amount', [ESCROW_PROGRAM_ERROR__INVALID_ARBITER]: 'Arbiter signer is missing or does not match', + [ESCROW_PROGRAM_ERROR__ESCROW_IMMUTABLE]: 'Escrow is immutable and cannot be modified', }; const FALLBACK_TX_FAILED_MESSAGE = 'Transaction failed'; 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/idl/escrow_program.json b/idl/escrow_program.json index 913ea37..c8a1f25 100644 --- a/idl/escrow_program.json +++ b/idl/escrow_program.json @@ -135,6 +135,18 @@ "type": { "kind": "publicKeyTypeNode" } + }, + { + "kind": "structFieldTypeNode", + "name": "isImmutable", + "type": { + "kind": "booleanTypeNode", + "size": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } } ], "kind": "structTypeNode" @@ -601,6 +613,29 @@ "kind": "structTypeNode" } }, + { + "kind": "definedTypeNode", + "name": "setImmutableEvent", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "escrow", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "admin", + "type": { + "kind": "publicKeyTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, { "kind": "definedTypeNode", "name": "withdrawEvent", @@ -744,6 +779,12 @@ "kind": "errorNode", "message": "Token extension is not currently blocked", "name": "tokenExtensionNotBlocked" + }, + { + "code": 16, + "kind": "errorNode", + "message": "Escrow is immutable and cannot be modified", + "name": "escrowImmutable" } ], "instructions": [ @@ -2775,6 +2816,79 @@ ], "kind": "instructionNode", "name": "unblockTokenExtension" + }, + { + "accounts": [ + { + "docs": [ + "Admin authority for the escrow" + ], + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "admin" + }, + { + "docs": [ + "Escrow account to lock as immutable" + ], + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "escrow" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M" + }, + "docs": [ + "Event authority PDA for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg" + }, + "docs": [ + "Escrow program for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "escrowProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 12 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "setImmutable" } ], "kind": "programNode", 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..66a4b0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@solana/web3.js': specifier: ^1.98.4 version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1(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))(react@19.2.4) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -145,7 +148,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 +156,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 +171,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 +1278,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'} @@ -3875,6 +3889,35 @@ packages: cpu: [x64] os: [win32] + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@wallet-standard/app@1.1.0': resolution: {integrity: sha512-3CijvrO9utx598kjr45hTbbeeykQrQfKmSnxeWOgU25TOEpvcipD/bYDQWIqUv1Oc6KK4YStokSMu/FBNecGUQ==} engines: {node: '>=16'} @@ -4661,6 +4704,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 +5105,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 +6322,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 +8634,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) @@ -12225,6 +12291,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/analytics@2.0.1(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))(react@19.2.4)': + optionalDependencies: + 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) + react: 19.2.4 + '@wallet-standard/app@1.1.0': dependencies: '@wallet-standard/base': 1.1.0 @@ -13517,6 +13588,8 @@ snapshots: dependencies: path-type: 4.0.0 + dotenv@16.6.1: {} + draggabilly@3.0.0: dependencies: get-size: 3.0.0 @@ -13963,6 +14036,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -15276,7 +15352,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 +15371,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 +15606,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: {} diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs index 644c2a8..c563901 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -4,7 +4,8 @@ use crate::{ instructions::{ process_add_timelock, process_allow_mint, process_block_mint, process_block_token_extension, process_create_escrow, process_deposit, process_emit_event, process_remove_extension, process_set_arbiter, - process_set_hook, process_unblock_token_extension, process_update_admin, process_withdraw, + process_set_hook, process_set_immutable, process_unblock_token_extension, process_update_admin, + process_withdraw, }, traits::EscrowInstructionDiscriminators, }; @@ -36,6 +37,7 @@ pub fn process_instruction(program_id: &Address, accounts: &[AccountView], instr process_unblock_token_extension(program_id, accounts, instruction_data) } EscrowInstructionDiscriminators::SetArbiter => process_set_arbiter(program_id, accounts, instruction_data), + EscrowInstructionDiscriminators::SetImmutable => process_set_immutable(program_id, accounts, instruction_data), EscrowInstructionDiscriminators::EmitEvent => process_emit_event(program_id, accounts), } } diff --git a/program/src/errors.rs b/program/src/errors.rs index d5919fa..df582e6 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -68,6 +68,10 @@ pub enum EscrowProgramError { /// (15) Token extension is not currently blocked #[error("Token extension is not currently blocked")] TokenExtensionNotBlocked, + + /// (16) Escrow is immutable and cannot be modified + #[error("Escrow is immutable and cannot be modified")] + EscrowImmutable, } impl From for ProgramError { @@ -99,5 +103,9 @@ mod tests { let error: ProgramError = EscrowProgramError::InvalidWithdrawer.into(); assert_eq!(error, ProgramError::Custom(5)); + + let error: ProgramError = EscrowProgramError::EscrowImmutable.into(); + assert_eq!(error, ProgramError::Custom(16)); + assert_eq!(error, ProgramError::Custom(16)); } } diff --git a/program/src/events/mod.rs b/program/src/events/mod.rs index 4d45a7e..dc71f8e 100644 --- a/program/src/events/mod.rs +++ b/program/src/events/mod.rs @@ -4,6 +4,7 @@ pub mod block_mint; pub mod create_escrow; pub mod deposit; pub mod extensions; +pub mod set_immutable; pub mod shared; pub mod withdraw; @@ -13,5 +14,6 @@ pub use block_mint::*; pub use create_escrow::*; pub use deposit::*; pub use extensions::*; +pub use set_immutable::*; pub use shared::*; pub use withdraw::*; diff --git a/program/src/events/set_immutable.rs b/program/src/events/set_immutable.rs new file mode 100644 index 0000000..58f4a71 --- /dev/null +++ b/program/src/events/set_immutable.rs @@ -0,0 +1,63 @@ +use alloc::vec::Vec; +use codama::CodamaType; +use pinocchio::Address; + +use crate::traits::{EventDiscriminator, EventDiscriminators, EventSerialize}; + +#[derive(CodamaType)] +pub struct SetImmutableEvent { + pub escrow: Address, + pub admin: Address, +} + +impl EventDiscriminator for SetImmutableEvent { + const DISCRIMINATOR: u8 = EventDiscriminators::SetImmutable as u8; +} + +impl EventSerialize for SetImmutableEvent { + #[inline(always)] + fn to_bytes_inner(&self) -> Vec { + let mut data = Vec::with_capacity(Self::DATA_LEN); + data.extend_from_slice(self.escrow.as_ref()); + data.extend_from_slice(self.admin.as_ref()); + data + } +} + +impl SetImmutableEvent { + pub const DATA_LEN: usize = 32 + 32; // escrow + admin + + #[inline(always)] + pub fn new(escrow: Address, admin: Address) -> Self { + Self { escrow, admin } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::EVENT_IX_TAG_LE; + use crate::traits::EVENT_DISCRIMINATOR_LEN; + + #[test] + fn test_set_immutable_event_new() { + let escrow = Address::new_from_array([1u8; 32]); + let admin = Address::new_from_array([2u8; 32]); + let event = SetImmutableEvent::new(escrow, admin); + + assert_eq!(event.escrow, escrow); + assert_eq!(event.admin, admin); + } + + #[test] + fn test_set_immutable_event_to_bytes() { + let escrow = Address::new_from_array([1u8; 32]); + let admin = Address::new_from_array([2u8; 32]); + let event = SetImmutableEvent::new(escrow, admin); + + let bytes = event.to_bytes(); + assert_eq!(bytes.len(), EVENT_DISCRIMINATOR_LEN + SetImmutableEvent::DATA_LEN); + assert_eq!(&bytes[..8], EVENT_IX_TAG_LE); + assert_eq!(bytes[8], EventDiscriminators::SetImmutable as u8); + } +} diff --git a/program/src/instructions/allow_mint/processor.rs b/program/src/instructions/allow_mint/processor.rs index db8ec87..0afa69f 100644 --- a/program/src/instructions/allow_mint/processor.rs +++ b/program/src/instructions/allow_mint/processor.rs @@ -20,6 +20,7 @@ pub fn process_allow_mint(program_id: &Address, accounts: &[AccountView], instru let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate AllowedMint PDA using external seeds let pda_seeds = AllowedMintPda::new(ix.accounts.escrow.address(), ix.accounts.mint.address()); diff --git a/program/src/instructions/block_mint/processor.rs b/program/src/instructions/block_mint/processor.rs index e1b12ff..70dea1f 100644 --- a/program/src/instructions/block_mint/processor.rs +++ b/program/src/instructions/block_mint/processor.rs @@ -18,6 +18,7 @@ pub fn process_block_mint(program_id: &Address, accounts: &[AccountView], instru let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Verify allowed_mint account exists and self-validates against escrow + mint PDA derivation let allowed_mint_data = ix.accounts.allowed_mint.try_borrow()?; diff --git a/program/src/instructions/create_escrow/processor.rs b/program/src/instructions/create_escrow/processor.rs index 9db2ed1..c4f84b0 100644 --- a/program/src/instructions/create_escrow/processor.rs +++ b/program/src/instructions/create_escrow/processor.rs @@ -16,7 +16,7 @@ pub fn process_create_escrow(program_id: &Address, accounts: &[AccountView], ins let ix = CreateEscrow::try_from((instruction_data, accounts))?; // Create Escrow state - let escrow = Escrow::new(ix.data.bump, *ix.accounts.escrow_seed.address(), *ix.accounts.admin.address()); + let escrow = Escrow::new(ix.data.bump, *ix.accounts.escrow_seed.address(), *ix.accounts.admin.address(), false); // Validate Escrow PDA escrow.validate_pda(ix.accounts.escrow, program_id, ix.data.bump)?; diff --git a/program/src/instructions/definition.rs b/program/src/instructions/definition.rs index 92c1bcc..d9552f0 100644 --- a/program/src/instructions/definition.rs +++ b/program/src/instructions/definition.rs @@ -303,7 +303,6 @@ pub enum EscrowProgramInstruction { } = 8, /// Set an arbiter on an escrow. The arbiter must sign withdrawal transactions. - /// This is immutable — once set, the arbiter cannot be changed. #[codama(account(name = "payer", signer, writable))] #[codama(account(name = "admin", signer))] #[codama(account(name = "arbiter", signer))] @@ -386,6 +385,21 @@ pub enum EscrowProgramInstruction { blocked_extension: u16, } = 11, + /// Lock an escrow so configuration can no longer be modified. + #[codama(account(name = "admin", docs = "Admin authority for the escrow", signer))] + #[codama(account(name = "escrow", docs = "Escrow account to lock as immutable", writable))] + #[codama(account( + name = "event_authority", + docs = "Event authority PDA for CPI event emission", + default_value = public_key("Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M") + ))] + #[codama(account( + name = "escrow_program", + docs = "Escrow program for CPI event emission", + default_value = public_key("Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg") + ))] + SetImmutable {} = 12, + /// Invoked via CPI to emit event data in instruction args (prevents log truncation). #[codama(skip)] #[codama(account( diff --git a/program/src/instructions/extensions/add_timelock/processor.rs b/program/src/instructions/extensions/add_timelock/processor.rs index 3ebd0ee..2e9c777 100644 --- a/program/src/instructions/extensions/add_timelock/processor.rs +++ b/program/src/instructions/extensions/add_timelock/processor.rs @@ -19,6 +19,7 @@ pub fn process_add_timelock(program_id: &Address, accounts: &[AccountView], inst let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/block_token_extension/processor.rs b/program/src/instructions/extensions/block_token_extension/processor.rs index ac23a6a..a710cf1 100644 --- a/program/src/instructions/extensions/block_token_extension/processor.rs +++ b/program/src/instructions/extensions/block_token_extension/processor.rs @@ -23,6 +23,7 @@ pub fn process_block_token_extension( let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/set_arbiter/processor.rs b/program/src/instructions/extensions/set_arbiter/processor.rs index bc60322..0679fcc 100644 --- a/program/src/instructions/extensions/set_arbiter/processor.rs +++ b/program/src/instructions/extensions/set_arbiter/processor.rs @@ -19,6 +19,7 @@ pub fn process_set_arbiter(program_id: &Address, accounts: &[AccountView], instr let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/set_hook/processor.rs b/program/src/instructions/extensions/set_hook/processor.rs index 8529194..6d5db29 100644 --- a/program/src/instructions/extensions/set_hook/processor.rs +++ b/program/src/instructions/extensions/set_hook/processor.rs @@ -19,6 +19,7 @@ pub fn process_set_hook(program_id: &Address, accounts: &[AccountView], instruct let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/impl_instructions.rs b/program/src/instructions/impl_instructions.rs index ffd0295..4457ec1 100644 --- a/program/src/instructions/impl_instructions.rs +++ b/program/src/instructions/impl_instructions.rs @@ -12,6 +12,7 @@ use super::extensions::{ set_hook::{SetHookAccounts, SetHookData}, unblock_token_extension::{UnblockTokenExtensionAccounts, UnblockTokenExtensionData}, }; +use super::set_immutable::{SetImmutableAccounts, SetImmutableData}; use super::update_admin::{UpdateAdminAccounts, UpdateAdminData}; use super::withdraw::{WithdrawAccounts, WithdrawData}; @@ -25,5 +26,6 @@ define_instruction!(RemoveExtension, RemoveExtensionAccounts, RemoveExtensionDat define_instruction!(SetArbiter, SetArbiterAccounts, SetArbiterData); define_instruction!(SetHook, SetHookAccounts, SetHookData); define_instruction!(UnblockTokenExtension, UnblockTokenExtensionAccounts, UnblockTokenExtensionData); +define_instruction!(SetImmutable, SetImmutableAccounts, SetImmutableData); define_instruction!(UpdateAdmin, UpdateAdminAccounts, UpdateAdminData); define_instruction!(Withdraw, WithdrawAccounts, WithdrawData); diff --git a/program/src/instructions/mod.rs b/program/src/instructions/mod.rs index 83cdcb2..84157df 100644 --- a/program/src/instructions/mod.rs +++ b/program/src/instructions/mod.rs @@ -6,6 +6,7 @@ pub mod deposit; pub mod emit_event; pub mod extensions; pub mod impl_instructions; +pub mod set_immutable; pub mod update_admin; pub mod withdraw; @@ -18,5 +19,6 @@ pub use deposit::*; pub use emit_event::*; pub use extensions::*; pub use impl_instructions::*; +pub use set_immutable::*; pub use update_admin::*; pub use withdraw::*; diff --git a/program/src/instructions/set_immutable/accounts.rs b/program/src/instructions/set_immutable/accounts.rs new file mode 100644 index 0000000..48e1827 --- /dev/null +++ b/program/src/instructions/set_immutable/accounts.rs @@ -0,0 +1,50 @@ +use pinocchio::{account::AccountView, error::ProgramError}; + +use crate::{ + traits::InstructionAccounts, + utils::{ + verify_current_program, verify_current_program_account, verify_event_authority, verify_signer, verify_writable, + }, +}; + +/// Accounts for the SetImmutable instruction +/// +/// # Account Layout +/// 0. `[signer]` admin - Current admin, must match escrow.admin +/// 1. `[writable]` escrow - Escrow account to lock as immutable +/// 2. `[]` event_authority - Event authority PDA +/// 3. `[]` escrow_program - Current program +pub struct SetImmutableAccounts<'a> { + pub admin: &'a AccountView, + pub escrow: &'a AccountView, + pub event_authority: &'a AccountView, + pub escrow_program: &'a AccountView, +} + +impl<'a> TryFrom<&'a [AccountView]> for SetImmutableAccounts<'a> { + type Error = ProgramError; + + #[inline(always)] + fn try_from(accounts: &'a [AccountView]) -> Result { + let [admin, escrow, event_authority, escrow_program] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // 1. Validate signers + verify_signer(admin, false)?; + + // 2. Validate writable + verify_writable(escrow, true)?; + + // 3. Validate program IDs + verify_current_program(escrow_program)?; + verify_event_authority(event_authority)?; + + // 4. Validate accounts owned by current program + verify_current_program_account(escrow)?; + + Ok(Self { admin, escrow, event_authority, escrow_program }) + } +} + +impl<'a> InstructionAccounts<'a> for SetImmutableAccounts<'a> {} diff --git a/program/src/instructions/set_immutable/data.rs b/program/src/instructions/set_immutable/data.rs new file mode 100644 index 0000000..fb2d896 --- /dev/null +++ b/program/src/instructions/set_immutable/data.rs @@ -0,0 +1,40 @@ +use pinocchio::error::ProgramError; + +use crate::traits::InstructionData; + +/// Instruction data for SetImmutable +/// +/// No additional data is required. +pub struct SetImmutableData; + +impl<'a> TryFrom<&'a [u8]> for SetImmutableData { + type Error = ProgramError; + + #[inline(always)] + fn try_from(_data: &'a [u8]) -> Result { + Ok(Self) + } +} + +impl<'a> InstructionData<'a> for SetImmutableData { + const LEN: usize = 0; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_immutable_data_try_from_empty() { + let data: [u8; 0] = []; + let result = SetImmutableData::try_from(&data[..]); + assert!(result.is_ok()); + } + + #[test] + fn test_set_immutable_data_try_from_with_extra_bytes() { + let data = [1u8, 2, 3]; + let result = SetImmutableData::try_from(&data[..]); + assert!(result.is_ok()); + } +} diff --git a/program/src/instructions/set_immutable/mod.rs b/program/src/instructions/set_immutable/mod.rs new file mode 100644 index 0000000..6769860 --- /dev/null +++ b/program/src/instructions/set_immutable/mod.rs @@ -0,0 +1,8 @@ +mod accounts; +mod data; +mod processor; + +pub use crate::instructions::impl_instructions::SetImmutable; +pub use accounts::*; +pub use data::*; +pub use processor::*; diff --git a/program/src/instructions/set_immutable/processor.rs b/program/src/instructions/set_immutable/processor.rs new file mode 100644 index 0000000..5bb5fc3 --- /dev/null +++ b/program/src/instructions/set_immutable/processor.rs @@ -0,0 +1,35 @@ +use pinocchio::{account::AccountView, Address, ProgramResult}; + +use crate::{ + events::SetImmutableEvent, + instructions::SetImmutable, + state::Escrow, + traits::{AccountSerialize, EventSerialize}, + utils::emit_event, +}; + +/// Processes the SetImmutable instruction. +/// +/// Locks an escrow configuration so it can no longer be modified. +pub fn process_set_immutable(program_id: &Address, accounts: &[AccountView], instruction_data: &[u8]) -> ProgramResult { + let ix = SetImmutable::try_from((instruction_data, accounts))?; + + // Read and validate escrow + let updated_escrow = { + let escrow_data = ix.accounts.escrow.try_borrow()?; + let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; + escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; + escrow.set_immutable() + }; + + // Write updated escrow. + let mut escrow_data = ix.accounts.escrow.try_borrow_mut()?; + updated_escrow.write_to_slice(&mut escrow_data)?; + + // Emit event + let event = SetImmutableEvent::new(*ix.accounts.escrow.address(), *ix.accounts.admin.address()); + emit_event(program_id, ix.accounts.event_authority, ix.accounts.escrow_program, &event.to_bytes())?; + + Ok(()) +} diff --git a/program/src/instructions/update_admin/processor.rs b/program/src/instructions/update_admin/processor.rs index 4682ab9..30236c0 100644 --- a/program/src/instructions/update_admin/processor.rs +++ b/program/src/instructions/update_admin/processor.rs @@ -18,10 +18,12 @@ pub fn process_update_admin(program_id: &Address, accounts: &[AccountView], inst let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Copy values we need for the update let old_admin = escrow.admin; - let updated_escrow = Escrow::new(escrow.bump, escrow.escrow_seed, *ix.accounts.new_admin.address()); + let updated_escrow = + Escrow::new(escrow.bump, escrow.escrow_seed, *ix.accounts.new_admin.address(), escrow.is_immutable); drop(escrow_data); // Write updated escrow diff --git a/program/src/state/escrow.rs b/program/src/state/escrow.rs index 3899e48..a2a99af 100644 --- a/program/src/state/escrow.rs +++ b/program/src/state/escrow.rs @@ -29,9 +29,10 @@ pub struct Escrow { pub bump: u8, pub escrow_seed: Address, pub admin: Address, + pub is_immutable: bool, } -assert_no_padding!(Escrow, 1 + 32 + 32); +assert_no_padding!(Escrow, 1 + 32 + 32 + 1); impl Discriminator for Escrow { const DISCRIMINATOR: u8 = EscrowAccountDiscriminators::EscrowDiscriminator as u8; @@ -42,7 +43,7 @@ impl Versioned for Escrow { } impl AccountSize for Escrow { - const DATA_LEN: usize = 1 + 32 + 32; // bump + escrow_seed + admin + const DATA_LEN: usize = 1 + 32 + 32 + 1; // bump + escrow_seed + admin + is_immutable } impl AccountDeserialize for Escrow {} @@ -54,6 +55,7 @@ impl AccountSerialize for Escrow { data.push(self.bump); data.extend_from_slice(self.escrow_seed.as_ref()); data.extend_from_slice(self.admin.as_ref()); + data.push(self.is_immutable as u8); data } } @@ -81,8 +83,8 @@ impl PdaAccount for Escrow { impl Escrow { #[inline(always)] - pub fn new(bump: u8, escrow_seed: Address, admin: Address) -> Self { - Self { bump, escrow_seed, admin } + pub fn new(bump: u8, escrow_seed: Address, admin: Address, is_immutable: bool) -> Self { + Self { bump, escrow_seed, admin, is_immutable } } #[inline(always)] @@ -104,6 +106,19 @@ impl Escrow { Ok(()) } + #[inline(always)] + pub fn require_mutable(&self) -> Result<(), ProgramError> { + if self.is_immutable { + return Err(EscrowProgramError::EscrowImmutable.into()); + } + Ok(()) + } + + #[inline(always)] + pub fn set_immutable(&self) -> Self { + Self::new(self.bump, self.escrow_seed, self.admin, true) + } + /// Execute a CPI with this escrow PDA as signer #[inline(always)] pub fn with_signer(&self, f: F) -> R @@ -124,7 +139,7 @@ mod tests { fn create_test_escrow() -> Escrow { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - Escrow::new(255, escrow_seed, admin) + Escrow::new(255, escrow_seed, admin, false) } #[test] @@ -132,11 +147,12 @@ mod tests { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(200, escrow_seed, admin); + let escrow = Escrow::new(200, escrow_seed, admin, false); assert_eq!(escrow.bump, 200); assert_eq!(escrow.escrow_seed, escrow_seed); assert_eq!(escrow.admin, admin); + assert!(!escrow.is_immutable); } #[test] @@ -165,6 +181,7 @@ mod tests { assert_eq!(bytes[0], 255); // bump assert_eq!(&bytes[1..33], &[1u8; 32]); // escrow_seed assert_eq!(&bytes[33..65], &[2u8; 32]); // admin + assert_eq!(bytes[65], 0); // is_immutable } #[test] @@ -176,6 +193,7 @@ mod tests { assert_eq!(bytes[0], Escrow::DISCRIMINATOR); assert_eq!(bytes[1], Escrow::VERSION); // version auto-prepended assert_eq!(bytes[2], 255); // bump + assert_eq!(bytes[67], 0); // is_immutable } #[test] @@ -188,6 +206,7 @@ mod tests { assert_eq!(deserialized.bump, escrow.bump); assert_eq!(deserialized.escrow_seed, escrow.escrow_seed); assert_eq!(deserialized.admin, escrow.admin); + assert_eq!(deserialized.is_immutable, escrow.is_immutable); } #[test] @@ -199,7 +218,7 @@ mod tests { #[test] fn test_escrow_from_bytes_wrong_discriminator() { - let mut bytes = [0u8; 67]; + let mut bytes = [0u8; 68]; bytes[0] = 99; // wrong discriminator let result = Escrow::from_bytes(&bytes); assert_eq!(result, Err(ProgramError::InvalidAccountData)); @@ -235,6 +254,23 @@ mod tests { assert_eq!(dest[2], escrow.bump); } + #[test] + fn test_set_immutable_sets_flag() { + let escrow = create_test_escrow(); + let immutable = escrow.set_immutable(); + assert!(immutable.is_immutable); + assert_eq!(immutable.bump, escrow.bump); + assert_eq!(immutable.admin, escrow.admin); + assert_eq!(immutable.escrow_seed, escrow.escrow_seed); + } + + #[test] + fn test_require_mutable_fails_when_immutable() { + let escrow = Escrow::new(1, Address::new_from_array([1u8; 32]), Address::new_from_array([2u8; 32]), true); + let result = escrow.require_mutable(); + assert_eq!(result, Err(EscrowProgramError::EscrowImmutable.into())); + } + #[test] fn test_escrow_write_to_slice_too_small() { let escrow = create_test_escrow(); diff --git a/program/src/traits/account.rs b/program/src/traits/account.rs index d80caa0..873b553 100644 --- a/program/src/traits/account.rs +++ b/program/src/traits/account.rs @@ -136,7 +136,7 @@ mod tests { fn test_from_bytes_mut_modifies_original() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut bytes = escrow.to_bytes(); { @@ -152,7 +152,7 @@ mod tests { fn test_from_bytes_unchecked_skips_discriminator_and_version() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let bytes = escrow.to_bytes(); // Skip discriminator (byte 0) and version (byte 1) @@ -174,7 +174,7 @@ mod tests { fn test_to_bytes_roundtrip() { let escrow_seed = Address::new_from_array([42u8; 32]); let admin = Address::new_from_array([99u8; 32]); - let escrow = Escrow::new(128, escrow_seed, admin); + let escrow = Escrow::new(128, escrow_seed, admin, false); let bytes = escrow.to_bytes(); let deserialized = Escrow::from_bytes(&bytes).unwrap(); @@ -188,7 +188,7 @@ mod tests { fn test_from_bytes_wrong_version() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut bytes = escrow.to_bytes(); bytes[1] = Escrow::VERSION.wrapping_add(1); @@ -200,7 +200,7 @@ mod tests { fn test_from_bytes_mut_wrong_version() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut bytes = escrow.to_bytes(); bytes[1] = Escrow::VERSION.wrapping_add(1); @@ -212,7 +212,7 @@ mod tests { fn test_write_to_slice_exact_size() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut dest = vec![0u8; Escrow::LEN]; assert!(escrow.write_to_slice(&mut dest).is_ok()); @@ -225,7 +225,7 @@ mod tests { fn test_version_auto_serialized() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let bytes = escrow.to_bytes(); diff --git a/program/src/traits/event.rs b/program/src/traits/event.rs index b97b0c7..2b40b41 100644 --- a/program/src/traits/event.rs +++ b/program/src/traits/event.rs @@ -20,6 +20,7 @@ pub enum EventDiscriminators { ArbiterSet = 9, ExtensionRemoved = 10, TokenExtensionUnblocked = 11, + SetImmutable = 12, } /// Event discriminator with Anchor-compatible prefix diff --git a/program/src/traits/instruction.rs b/program/src/traits/instruction.rs index 867efe1..1700147 100644 --- a/program/src/traits/instruction.rs +++ b/program/src/traits/instruction.rs @@ -15,6 +15,7 @@ pub enum EscrowInstructionDiscriminators { SetArbiter = 9, RemoveExtension = 10, UnblockTokenExtension = 11, + SetImmutable = 12, EmitEvent = 228, } @@ -35,6 +36,7 @@ impl TryFrom for EscrowInstructionDiscriminators { 9 => Ok(Self::SetArbiter), 10 => Ok(Self::RemoveExtension), 11 => Ok(Self::UnblockTokenExtension), + 12 => Ok(Self::SetImmutable), 228 => Ok(Self::EmitEvent), _ => Err(ProgramError::InvalidInstructionData), } @@ -165,8 +167,15 @@ mod tests { } #[test] - fn test_discriminator_try_from_invalid() { + fn test_discriminator_try_from_set_immutable() { let result = EscrowInstructionDiscriminators::try_from(12u8); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), EscrowInstructionDiscriminators::SetImmutable)); + } + + #[test] + fn test_discriminator_try_from_invalid() { + let result = EscrowInstructionDiscriminators::try_from(13u8); assert!(matches!(result, Err(ProgramError::InvalidInstructionData))); let result = EscrowInstructionDiscriminators::try_from(255u8); diff --git a/program/src/traits/pda.rs b/program/src/traits/pda.rs index 05b4114..174f127 100644 --- a/program/src/traits/pda.rs +++ b/program/src/traits/pda.rs @@ -92,7 +92,7 @@ mod tests { fn test_derive_address_deterministic() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(0, escrow_seed, admin); + let escrow = Escrow::new(0, escrow_seed, admin, false); let (address1, bump1) = escrow.derive_address(&ID); let (address2, bump2) = escrow.derive_address(&ID); @@ -105,8 +105,8 @@ mod tests { fn test_derive_address_different_seeds() { let admin = Address::new_from_array([2u8; 32]); - let escrow1 = Escrow::new(0, Address::new_from_array([1u8; 32]), admin); - let escrow2 = Escrow::new(0, Address::new_from_array([3u8; 32]), admin); + let escrow1 = Escrow::new(0, Address::new_from_array([1u8; 32]), admin, false); + let escrow2 = Escrow::new(0, Address::new_from_array([3u8; 32]), admin, false); let (address1, _) = escrow1.derive_address(&ID); let (address2, _) = escrow2.derive_address(&ID); diff --git a/tests/integration-tests/src/fixtures/mod.rs b/tests/integration-tests/src/fixtures/mod.rs index b43e3be..78d3832 100644 --- a/tests/integration-tests/src/fixtures/mod.rs +++ b/tests/integration-tests/src/fixtures/mod.rs @@ -7,6 +7,7 @@ pub mod deposit; pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; +pub mod set_immutable; pub mod unblock_token_extension; pub mod update_admin; pub mod withdraw; @@ -20,6 +21,7 @@ pub use deposit::{DepositFixture, DepositSetup, DEFAULT_DEPOSIT_AMOUNT}; pub use remove_extension::RemoveExtensionFixture; pub use set_arbiter::SetArbiterFixture; pub use set_hook::SetHookFixture; +pub use set_immutable::SetImmutableFixture; pub use unblock_token_extension::UnblockTokenExtensionFixture; pub use update_admin::UpdateAdminFixture; pub use withdraw::{WithdrawFixture, WithdrawSetup}; diff --git a/tests/integration-tests/src/fixtures/set_immutable.rs b/tests/integration-tests/src/fixtures/set_immutable.rs new file mode 100644 index 0000000..cd207fa --- /dev/null +++ b/tests/integration-tests/src/fixtures/set_immutable.rs @@ -0,0 +1,63 @@ +use escrow_program_client::instructions::SetImmutableBuilder; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +use crate::{ + fixtures::CreateEscrowFixture, + utils::{find_escrow_pda, TestContext}, +}; + +use crate::utils::traits::{InstructionTestFixture, TestInstruction}; + +pub struct SetImmutableFixture; + +impl SetImmutableFixture { + pub fn build_with_escrow(_ctx: &mut TestContext, escrow_pda: Pubkey, admin: Keypair) -> TestInstruction { + let instruction = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + + TestInstruction { instruction, signers: vec![admin], name: Self::INSTRUCTION_NAME } + } +} + +impl InstructionTestFixture for SetImmutableFixture { + const INSTRUCTION_NAME: &'static str = "SetImmutable"; + + fn build_valid(ctx: &mut TestContext) -> TestInstruction { + let escrow_ix = CreateEscrowFixture::build_valid(ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + + let instruction = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + + TestInstruction { instruction, signers: vec![admin], name: Self::INSTRUCTION_NAME } + } + + /// Account indices that must be signers: + /// 0: admin + fn required_signers() -> &'static [usize] { + &[0] + } + + /// Account indices that must be writable: + /// 1: escrow + fn required_writable() -> &'static [usize] { + &[1] + } + + fn system_program_index() -> Option { + None + } + + fn current_program_index() -> Option { + Some(3) + } + + fn data_len() -> usize { + 1 // Just the discriminator + } +} diff --git a/tests/integration-tests/src/lib.rs b/tests/integration-tests/src/lib.rs index 6e76ab7..264b12b 100644 --- a/tests/integration-tests/src/lib.rs +++ b/tests/integration-tests/src/lib.rs @@ -20,6 +20,8 @@ mod test_set_arbiter; #[cfg(test)] mod test_set_hook; #[cfg(test)] +mod test_set_immutable; +#[cfg(test)] mod test_unblock_token_extension; #[cfg(test)] mod test_update_admin; diff --git a/tests/integration-tests/src/test_add_timelock.rs b/tests/integration-tests/src/test_add_timelock.rs index d61ec95..df558f9 100644 --- a/tests/integration-tests/src/test_add_timelock.rs +++ b/tests/integration-tests/src/test_add_timelock.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddTimelockFixture, CreateEscrowFixture}, + fixtures::{AddTimelockFixture, CreateEscrowFixture, SetImmutableFixture}, utils::{ - assert_extensions_header, assert_instruction_error, assert_timelock_extension, find_escrow_pda, - find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, test_truncated_data, - test_wrong_account, test_wrong_current_program, test_wrong_system_program, InstructionTestFixture, TestContext, - RANDOM_PUBKEY, + assert_escrow_error, assert_extensions_header, assert_instruction_error, assert_timelock_extension, + find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, + test_truncated_data, test_wrong_account, test_wrong_current_program, test_wrong_system_program, EscrowError, + InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use solana_sdk::{instruction::InstructionError, signature::Signer}; @@ -94,6 +94,24 @@ fn test_add_timelock_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_add_timelock_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let add_timelock_ix = AddTimelockFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 3600); + let error = add_timelock_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_add_timelock_updates_existing_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_allow_mint.rs b/tests/integration-tests/src/test_allow_mint.rs index d1c1719..173c236 100644 --- a/tests/integration-tests/src/test_allow_mint.rs +++ b/tests/integration-tests/src/test_allow_mint.rs @@ -6,7 +6,7 @@ use crate::{ test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; -use escrow_program_client::instructions::AllowMintBuilder; +use escrow_program_client::instructions::{AllowMintBuilder, SetImmutableBuilder}; use solana_sdk::{account::Account, instruction::InstructionError, pubkey::Pubkey, signature::Signer}; use spl_associated_token_account::get_associated_token_address; use spl_token_2022::extension::ExtensionType; @@ -131,6 +131,20 @@ fn test_allow_mint_duplicate() { assert!(matches!(error, solana_sdk::transaction::TransactionError::AlreadyProcessed)); } +#[test] +fn test_allow_mint_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + let setup = AllowMintSetup::new(&mut ctx); + + let set_immutable_ix = + SetImmutableBuilder::new().admin(setup.admin.pubkey()).escrow(setup.escrow_pda).instruction(); + ctx.send_transaction(set_immutable_ix, &[&setup.admin]).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + // ============================================================================ // Happy Path Test // ============================================================================ diff --git a/tests/integration-tests/src/test_block_mint.rs b/tests/integration-tests/src/test_block_mint.rs index 0503a7c..53e780f 100644 --- a/tests/integration-tests/src/test_block_mint.rs +++ b/tests/integration-tests/src/test_block_mint.rs @@ -6,7 +6,7 @@ use crate::{ InstructionTestFixture, TestContext, TestInstruction, RANDOM_PUBKEY, }, }; -use escrow_program_client::instructions::{AllowMintBuilder, BlockMintBuilder}; +use escrow_program_client::instructions::{AllowMintBuilder, BlockMintBuilder, SetImmutableBuilder}; use solana_sdk::{instruction::InstructionError, signature::Signer}; use spl_associated_token_account::get_associated_token_address; @@ -61,6 +61,20 @@ fn test_block_mint_wrong_admin() { assert_escrow_error(error, EscrowError::InvalidAdmin); } +#[test] +fn test_block_mint_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + let setup = BlockMintSetup::new(&mut ctx); + + let set_immutable_ix = + SetImmutableBuilder::new().admin(setup.admin.pubkey()).escrow(setup.escrow_pda).instruction(); + ctx.send_transaction(set_immutable_ix, &[&setup.admin]).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_block_mint_wrong_escrow() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_block_token_extension.rs b/tests/integration-tests/src/test_block_token_extension.rs index f857585..06bd970 100644 --- a/tests/integration-tests/src/test_block_token_extension.rs +++ b/tests/integration-tests/src/test_block_token_extension.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddBlockTokenExtensionsFixture, CreateEscrowFixture}, + fixtures::{AddBlockTokenExtensionsFixture, CreateEscrowFixture, SetImmutableFixture}, utils::{ - assert_block_token_extensions_extension, assert_extensions_header, assert_instruction_error, find_escrow_pda, - find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, test_truncated_data, - test_wrong_account, test_wrong_current_program, test_wrong_system_program, InstructionTestFixture, TestContext, - RANDOM_PUBKEY, + assert_block_token_extensions_extension, assert_escrow_error, assert_extensions_header, + assert_instruction_error, find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, + test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use solana_sdk::{instruction::InstructionError, signature::Signer}; @@ -87,6 +87,24 @@ fn test_block_token_extension_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_block_token_extension_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let block_ext_ix = AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 1u16); + let error = block_ext_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_block_token_extension_duplicate_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_create_escrow.rs b/tests/integration-tests/src/test_create_escrow.rs index 5a1afd9..5d312ce 100644 --- a/tests/integration-tests/src/test_create_escrow.rs +++ b/tests/integration-tests/src/test_create_escrow.rs @@ -1,8 +1,9 @@ use crate::{ fixtures::CreateEscrowFixture, utils::{ - assert_escrow_account, assert_instruction_error, test_empty_data, test_missing_signer, test_not_writable, - test_wrong_account, test_wrong_current_program, test_wrong_system_program, InstructionTestFixture, TestContext, + assert_escrow_account, assert_escrow_mutability, assert_instruction_error, test_empty_data, + test_missing_signer, test_not_writable, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, InstructionTestFixture, TestContext, }, }; use escrow_program_client::instructions::CreatesEscrowBuilder; @@ -92,6 +93,7 @@ fn test_create_escrow_success() { test_ix.send_expect_success(&mut ctx); assert_escrow_account(&ctx, &escrow_pda, &admin_pubkey, bump, &escrow_seed_pubkey); + assert_escrow_mutability(&ctx, &escrow_pda, false); } #[test] @@ -115,6 +117,7 @@ fn test_create_escrow_prefunded_pda_succeeds() { test_ix.send_expect_success(&mut ctx); assert_escrow_account(&ctx, &escrow_pda, &admin_pubkey, bump, &escrow_seed_pubkey); + assert_escrow_mutability(&ctx, &escrow_pda, false); } // ============================================================================ diff --git a/tests/integration-tests/src/test_deposit.rs b/tests/integration-tests/src/test_deposit.rs index 62cbea7..f8b9571 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -1,6 +1,6 @@ use crate::{ fixtures::{ - AddBlockTokenExtensionsFixture, DepositFixture, DepositSetup, UnblockTokenExtensionFixture, + AddBlockTokenExtensionsFixture, AllowMintSetup, DepositFixture, DepositSetup, UnblockTokenExtensionFixture, DEFAULT_DEPOSIT_AMOUNT, }, utils::{ @@ -10,13 +10,12 @@ use crate::{ TEST_HOOK_ALLOW_ID, TEST_HOOK_DENY_ERROR, TEST_HOOK_DENY_ID, }, }; -use escrow_program_client::instructions::AddTimelockBuilder; use escrow_program_client::instructions::DepositBuilder; use solana_sdk::{ account::Account, instruction::{AccountMeta, InstructionError}, pubkey::Pubkey, - signature::Signer, + signature::{Keypair, Signer}, }; use spl_token_2022::extension::ExtensionType; use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; @@ -118,20 +117,51 @@ fn test_deposit_wrong_allowed_mint_owner() { } #[test] -fn test_deposit_initialized_extensions_wrong_owner() { +fn test_deposit_succeeds_when_escrow_is_mutable() { let mut ctx = TestContext::new(); - let setup = DepositSetup::new(&mut ctx); + let setup = AllowMintSetup::new(&mut ctx); + setup.build_instruction(&ctx).send_expect_success(&mut ctx); + + let depositor = ctx.create_funded_keypair(); + let depositor_token_account = + ctx.create_token_account_with_balance(&depositor.pubkey(), &setup.mint_pubkey, DEFAULT_DEPOSIT_AMOUNT * 10); + let initial_depositor_balance = ctx.get_token_balance(&depositor_token_account); + let initial_vault_balance = ctx.get_token_balance(&setup.vault); + let receipt_seed = Keypair::new(); + let (receipt_pda, bump) = + find_receipt_pda(&setup.escrow_pda, &depositor.pubkey(), &setup.mint_pubkey, &receipt_seed.pubkey()); - let (extensions_pda, extensions_bump) = crate::utils::find_extensions_pda(&setup.escrow_pda); - let add_timelock_ix = AddTimelockBuilder::new() + let instruction = DepositBuilder::new() .payer(ctx.payer.pubkey()) - .admin(setup.admin.pubkey()) + .depositor(depositor.pubkey()) .escrow(setup.escrow_pda) - .extensions(extensions_pda) - .extensions_bump(extensions_bump) - .lock_duration(1) + .allowed_mint(setup.allowed_mint_pda) + .receipt_seed(receipt_seed.pubkey()) + .receipt(receipt_pda) + .vault(setup.vault) + .depositor_token_account(depositor_token_account) + .mint(setup.mint_pubkey) + .token_program(setup.token_program) + .extensions(setup.escrow_extensions_pda) + .bump(bump) + .amount(DEFAULT_DEPOSIT_AMOUNT) .instruction(); - ctx.send_transaction(add_timelock_ix, &[&setup.admin]).unwrap(); + + ctx.send_transaction(instruction, &[&depositor, &receipt_seed]).unwrap(); + + let final_depositor_balance = ctx.get_token_balance(&depositor_token_account); + let final_vault_balance = ctx.get_token_balance(&setup.vault); + assert_eq!(final_depositor_balance, initial_depositor_balance - DEFAULT_DEPOSIT_AMOUNT); + assert_eq!(final_vault_balance, initial_vault_balance + DEFAULT_DEPOSIT_AMOUNT); + + let receipt_account = ctx.get_account(&receipt_pda).expect("Deposit receipt should exist"); + assert!(!receipt_account.data.is_empty()); +} + +#[test] +fn test_deposit_initialized_extensions_wrong_owner() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); extensions_account.owner = Pubkey::new_unique(); @@ -145,7 +175,9 @@ fn test_deposit_initialized_extensions_wrong_owner() { #[test] fn test_deposit_rejects_newly_blocked_mint_extension() { let mut ctx = TestContext::new(); - let setup = DepositSetup::builder(&mut ctx).mint_extension(ExtensionType::MetadataPointer).build(); + let setup = AllowMintSetup::builder(&mut ctx).mint_extension(ExtensionType::MetadataPointer).build(); + + setup.build_instruction(&ctx).send_expect_success(&mut ctx); let block_extension_ix = AddBlockTokenExtensionsFixture::build_with_escrow( &mut ctx, @@ -155,8 +187,30 @@ fn test_deposit_rejects_newly_blocked_mint_extension() { ); block_extension_ix.send_expect_success(&mut ctx); - let test_ix = setup.build_instruction(&ctx); - let error = test_ix.send_expect_error(&mut ctx); + let depositor = ctx.create_funded_keypair(); + let depositor_token_account = + ctx.create_token_2022_account_with_balance(&depositor.pubkey(), &setup.mint_pubkey, DEFAULT_DEPOSIT_AMOUNT); + let receipt_seed = Keypair::new(); + let (receipt_pda, bump) = + find_receipt_pda(&setup.escrow_pda, &depositor.pubkey(), &setup.mint_pubkey, &receipt_seed.pubkey()); + + let instruction = DepositBuilder::new() + .payer(ctx.payer.pubkey()) + .depositor(depositor.pubkey()) + .escrow(setup.escrow_pda) + .allowed_mint(setup.allowed_mint_pda) + .receipt_seed(receipt_seed.pubkey()) + .receipt(receipt_pda) + .vault(setup.vault) + .depositor_token_account(depositor_token_account) + .mint(setup.mint_pubkey) + .token_program(setup.token_program) + .extensions(setup.escrow_extensions_pda) + .bump(bump) + .amount(DEFAULT_DEPOSIT_AMOUNT) + .instruction(); + + let error = ctx.send_transaction_expect_error(instruction, &[&depositor, &receipt_seed]); assert_escrow_error(error, EscrowError::MintNotAllowed); } diff --git a/tests/integration-tests/src/test_set_arbiter.rs b/tests/integration-tests/src/test_set_arbiter.rs index aad3cf2..deb03b7 100644 --- a/tests/integration-tests/src/test_set_arbiter.rs +++ b/tests/integration-tests/src/test_set_arbiter.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddTimelockFixture, CreateEscrowFixture, SetArbiterFixture, SetHookFixture}, + fixtures::{AddTimelockFixture, CreateEscrowFixture, SetArbiterFixture, SetHookFixture, SetImmutableFixture}, utils::{ - assert_arbiter_extension, assert_extensions_header, assert_hook_extension, assert_instruction_error, - assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, - test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, - test_wrong_system_program, InstructionTestFixture, TestContext, RANDOM_PUBKEY, + assert_arbiter_extension, assert_escrow_error, assert_extensions_header, assert_hook_extension, + assert_instruction_error, assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, + test_missing_signer, test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use solana_sdk::{ @@ -105,6 +105,24 @@ fn test_set_arbiter_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_set_arbiter_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let arbiter_ix = SetArbiterFixture::build_with_escrow(&mut ctx, escrow_pda, admin, Keypair::new()); + let error = arbiter_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_set_arbiter_updates_existing_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_set_hook.rs b/tests/integration-tests/src/test_set_hook.rs index 98cc556..1a01cf7 100644 --- a/tests/integration-tests/src/test_set_hook.rs +++ b/tests/integration-tests/src/test_set_hook.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddTimelockFixture, CreateEscrowFixture, SetHookFixture}, + fixtures::{AddTimelockFixture, CreateEscrowFixture, SetHookFixture, SetImmutableFixture}, utils::{ - assert_extensions_header, assert_hook_extension, assert_instruction_error, assert_timelock_extension, - find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, - test_truncated_data, test_wrong_account, test_wrong_current_program, test_wrong_system_program, - InstructionTestFixture, TestContext, RANDOM_PUBKEY, + assert_escrow_error, assert_extensions_header, assert_hook_extension, assert_instruction_error, + assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, + test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use escrow_program_client::instructions::SetHookBuilder; @@ -97,6 +97,24 @@ fn test_set_hook_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_set_hook_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let hook_ix = SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin, Pubkey::new_unique()); + let error = hook_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_set_hook_updates_existing_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_set_immutable.rs b/tests/integration-tests/src/test_set_immutable.rs new file mode 100644 index 0000000..8df1bfd --- /dev/null +++ b/tests/integration-tests/src/test_set_immutable.rs @@ -0,0 +1,100 @@ +use crate::{ + fixtures::{CreateEscrowFixture, SetImmutableFixture}, + utils::{ + assert_escrow_error, assert_escrow_mutability, find_escrow_pda, test_empty_data, test_missing_signer, + test_not_writable, test_wrong_account, test_wrong_current_program, InstructionTestFixture, TestContext, + }, +}; +use solana_sdk::{instruction::InstructionError, signature::Signer}; + +// ============================================================================ +// Error Tests - Using Generic Test Helpers +// ============================================================================ + +#[test] +fn test_set_immutable_missing_admin_signer() { + let mut ctx = TestContext::new(); + test_missing_signer::(&mut ctx, 0, 0); +} + +#[test] +fn test_set_immutable_escrow_not_writable() { + let mut ctx = TestContext::new(); + test_not_writable::(&mut ctx, 1); +} + +#[test] +fn test_set_immutable_wrong_current_program() { + let mut ctx = TestContext::new(); + test_wrong_current_program::(&mut ctx); +} + +#[test] +fn test_set_immutable_invalid_event_authority() { + let mut ctx = TestContext::new(); + test_wrong_account::(&mut ctx, 2, InstructionError::Custom(2)); +} + +#[test] +fn test_set_immutable_empty_data() { + let mut ctx = TestContext::new(); + test_empty_data::(&mut ctx); +} + +#[test] +fn test_set_immutable_wrong_admin() { + let mut ctx = TestContext::new(); + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let wrong_admin = ctx.create_funded_keypair(); + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let test_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, wrong_admin); + + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, escrow_program_client::errors::EscrowProgramError::InvalidAdmin); +} + +// ============================================================================ +// Happy Path Tests +// ============================================================================ + +#[test] +fn test_set_immutable_success() { + let mut ctx = TestContext::new(); + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + assert_escrow_mutability(&ctx, &escrow_pda, false); + + let test_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin); + test_ix.send_expect_success(&mut ctx); + + assert_escrow_mutability(&ctx, &escrow_pda, true); +} + +#[test] +fn test_set_immutable_fails_when_already_immutable() { + let mut ctx = TestContext::new(); + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + + let first_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + first_ix.send_expect_success(&mut ctx); + + ctx.warp_to_slot(2); + + let second_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin); + let error = second_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, escrow_program_client::errors::EscrowProgramError::EscrowImmutable); + + assert_escrow_mutability(&ctx, &escrow_pda, true); +} diff --git a/tests/integration-tests/src/test_update_admin.rs b/tests/integration-tests/src/test_update_admin.rs index 36c576c..d5b1fc7 100644 --- a/tests/integration-tests/src/test_update_admin.rs +++ b/tests/integration-tests/src/test_update_admin.rs @@ -1,12 +1,12 @@ use crate::{ fixtures::{CreateEscrowFixture, UpdateAdminFixture}, utils::{ - assert_escrow_account, assert_instruction_error, find_escrow_pda, test_missing_signer, test_not_writable, - test_wrong_account, test_wrong_current_program, InstructionTestFixture, TestContext, TestInstruction, - RANDOM_PUBKEY, + assert_escrow_account, assert_escrow_error, assert_instruction_error, find_escrow_pda, test_missing_signer, + test_not_writable, test_wrong_account, test_wrong_current_program, EscrowError, InstructionTestFixture, + TestContext, TestInstruction, RANDOM_PUBKEY, }, }; -use escrow_program_client::instructions::UpdateAdminBuilder; +use escrow_program_client::instructions::{SetImmutableBuilder, UpdateAdminBuilder}; use solana_sdk::{ instruction::InstructionError, signature::{Keypair, Signer}, @@ -78,6 +78,25 @@ fn test_update_admin_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_update_admin_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + ctx.send_transaction(set_immutable_ix, &[&admin]).unwrap(); + + let new_admin = Keypair::new(); + let update_ix = UpdateAdminFixture::build_with_escrow(&mut ctx, escrow_pda, admin, new_admin); + let error = update_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + // ============================================================================ // Success Tests // ============================================================================ diff --git a/tests/integration-tests/src/test_withdraw.rs b/tests/integration-tests/src/test_withdraw.rs index a17b420..53dbf02 100644 --- a/tests/integration-tests/src/test_withdraw.rs +++ b/tests/integration-tests/src/test_withdraw.rs @@ -1,14 +1,12 @@ use crate::{ fixtures::{AllowMintSetup, WithdrawFixture, WithdrawSetup, DEFAULT_DEPOSIT_AMOUNT}, utils::{ - assert_custom_error, assert_escrow_error, assert_instruction_error, find_allowed_mint_pda, test_missing_signer, - test_not_writable, test_wrong_account, test_wrong_current_program, test_wrong_owner, test_wrong_system_program, + assert_custom_error, assert_escrow_error, assert_instruction_error, test_missing_signer, test_not_writable, + test_wrong_account, test_wrong_current_program, test_wrong_owner, test_wrong_system_program, test_wrong_token_program, EscrowError, TestContext, TestInstruction, TEST_HOOK_ALLOW_ID, TEST_HOOK_DENY_ERROR, TEST_HOOK_DENY_ID, }, }; -use escrow_program_client::instructions::AddTimelockBuilder; -use escrow_program_client::instructions::AllowMintBuilder; use escrow_program_client::instructions::WithdrawBuilder; use solana_sdk::{ account::Account, @@ -99,18 +97,7 @@ fn test_withdraw_wrong_receipt_owner() { #[test] fn test_withdraw_initialized_extensions_wrong_owner() { let mut ctx = TestContext::new(); - let setup = WithdrawSetup::new(&mut ctx); - - let (extensions_pda, extensions_bump) = crate::utils::find_extensions_pda(&setup.escrow_pda); - let add_timelock_ix = AddTimelockBuilder::new() - .payer(ctx.payer.pubkey()) - .admin(setup.admin.pubkey()) - .escrow(setup.escrow_pda) - .extensions(extensions_pda) - .extensions_bump(extensions_bump) - .lock_duration(1) - .instruction(); - ctx.send_transaction(add_timelock_ix, &[&setup.admin]).unwrap(); + let setup = WithdrawSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); extensions_account.owner = Pubkey::new_unique(); @@ -432,9 +419,13 @@ fn test_withdraw_with_hook_success() { #[test] fn test_withdraw_with_hook_rejected() { let mut ctx = TestContext::new(); + let mut setup = WithdrawSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); - let mut setup = WithdrawSetup::new(&mut ctx); - setup.set_hook(&mut ctx, TEST_HOOK_DENY_ID); + // Patch the hook extension directly to simulate a deny hook for withdraw-path rejection coverage. + let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); + extensions_account.data[8..40].copy_from_slice(&TEST_HOOK_DENY_ID.to_bytes()); + ctx.svm.set_account(setup.extensions_pda, extensions_account).unwrap(); + setup.hook_program = Some(TEST_HOOK_DENY_ID); let initial_vault_balance = ctx.get_token_balance(&setup.vault); @@ -507,21 +498,6 @@ fn test_withdraw_receipt_mint_mismatch_fails() { ctx.create_token_account_with_balance(&setup.escrow_pda, &second_mint.pubkey(), DEFAULT_DEPOSIT_AMOUNT); let second_withdrawer_token_account = ctx.create_token_account(&setup.depositor.pubkey(), &second_mint.pubkey()); - let (second_allowed_mint, second_allowed_mint_bump) = - find_allowed_mint_pda(&setup.escrow_pda, &second_mint.pubkey()); - let allow_second_mint_ix = AllowMintBuilder::new() - .payer(ctx.payer.pubkey()) - .admin(setup.admin.pubkey()) - .escrow(setup.escrow_pda) - .escrow_extensions(setup.extensions_pda) - .mint(second_mint.pubkey()) - .allowed_mint(second_allowed_mint) - .vault(second_vault) - .token_program(setup.token_program) - .bump(second_allowed_mint_bump) - .instruction(); - ctx.send_transaction(allow_second_mint_ix, &[&setup.admin]).unwrap(); - let instruction = WithdrawBuilder::new() .rent_recipient(ctx.payer.pubkey()) .withdrawer(setup.depositor.pubkey()) @@ -684,8 +660,9 @@ fn test_withdraw_with_arbiter_success() { #[test] fn test_withdraw_with_arbiter_missing_signer() { let mut ctx = TestContext::new(); - let mut setup = WithdrawSetup::new(&mut ctx); - let arbiter = setup.set_arbiter(&mut ctx); + let setup = WithdrawSetup::new_with_arbiter(&mut ctx); + let arbiter = + setup.arbiter.as_ref().expect("arbiter should be configured by WithdrawSetup::new_with_arbiter").pubkey(); // Build instruction manually without arbiter as signer let mut builder = WithdrawBuilder::new(); @@ -701,7 +678,7 @@ fn test_withdraw_with_arbiter_missing_signer() { .token_program(setup.token_program); // Add arbiter as non-signer (should fail) - builder.add_remaining_account(AccountMeta::new_readonly(arbiter.pubkey(), false)); + builder.add_remaining_account(AccountMeta::new_readonly(arbiter, false)); let instruction = builder.instruction(); let test_ix = TestInstruction { instruction, signers: vec![setup.depositor.insecure_clone()], name: "Withdraw" }; @@ -713,8 +690,7 @@ fn test_withdraw_with_arbiter_missing_signer() { #[test] fn test_withdraw_with_arbiter_wrong_address() { let mut ctx = TestContext::new(); - let mut setup = WithdrawSetup::new(&mut ctx); - setup.set_arbiter(&mut ctx); + let setup = WithdrawSetup::new_with_arbiter(&mut ctx); // Build instruction with wrong arbiter address let wrong_arbiter = ctx.create_funded_keypair(); @@ -747,8 +723,7 @@ fn test_withdraw_with_arbiter_wrong_address() { #[test] fn test_withdraw_with_arbiter_no_remaining_accounts() { let mut ctx = TestContext::new(); - let mut setup = WithdrawSetup::new(&mut ctx); - setup.set_arbiter(&mut ctx); + let setup = WithdrawSetup::new_with_arbiter(&mut ctx); // Build instruction without any remaining accounts (arbiter required but missing) let instruction = WithdrawBuilder::new() diff --git a/tests/integration-tests/src/utils/assertions.rs b/tests/integration-tests/src/utils/assertions.rs index 4c3dbe3..0c72003 100644 --- a/tests/integration-tests/src/utils/assertions.rs +++ b/tests/integration-tests/src/utils/assertions.rs @@ -61,6 +61,12 @@ pub fn assert_escrow_account( assert_eq!(escrow.escrow_seed.as_ref(), expected_escrow_seed.as_ref()); } +pub fn assert_escrow_mutability(context: &TestContext, escrow_pda: &Pubkey, expected_is_immutable: bool) { + let account = context.get_account(escrow_pda).expect("Escrow account should exist"); + let escrow = Escrow::from_bytes(&account.data).expect("Should deserialize escrow account"); + assert_eq!(escrow.is_immutable, expected_is_immutable, "Unexpected escrow mutability for {escrow_pda}"); +} + pub fn assert_extensions_header( ctx: &TestContext, extensions_pda: &Pubkey, diff --git a/tests/test-hook-program/src/lib.rs b/tests/test-hook-program/src/lib.rs index 3e4b72f..bc814e5 100644 --- a/tests/test-hook-program/src/lib.rs +++ b/tests/test-hook-program/src/lib.rs @@ -15,11 +15,7 @@ pinocchio::default_allocator!(); pinocchio::nostd_panic_handler!(); #[cfg(feature = "allow")] -pub fn process_instruction( - _program_id: &Address, - accounts: &[AccountView], - instruction_data: &[u8], -) -> ProgramResult { +pub fn process_instruction(_program_id: &Address, accounts: &[AccountView], instruction_data: &[u8]) -> ProgramResult { use pinocchio::error::ProgramError; // Validate core context shape so integration tests catch missing account context.