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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
# AI
.claude/

# Playwright
**/playwright-report/
**/test-results/
**/.playwright-mcp/

# Solana Test Validator
**/test-ledger
.runbook-logs/*
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ clients/typescript/src/generated/
dist/
build/
target/
**/.next/
**/playwright-report/
**/test-results/

# Dependencies
node_modules/
Expand Down
327 changes: 327 additions & 0 deletions apps/web/e2e/escrow-ui.spec.ts

Large diffs are not rendered by default.

164 changes: 164 additions & 0 deletions apps/web/e2e/helpers/wallet.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
await page.evaluate(key => {
(window as any)._walletKey = key;
}, walletKeyBase58);

await page.evaluate(
() =>
new Promise<void>((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<string, ((...args: any[]) => 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<void> {
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 });
}
8 changes: 6 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -32,7 +34,9 @@
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"dotenv": "^16.4.7",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"@playwright/test": "^1.50.0"
}
}
25 changes: 25 additions & 0 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
});
71 changes: 68 additions & 3 deletions apps/web/src/components/instructions/Withdraw.tsx
Original file line number Diff line number Diff line change
@@ -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('');
Expand All @@ -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,
Expand All @@ -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 || '' },
});
Expand Down
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export default [
'**/target/**',
'**/generated/**',
'clients/typescript/src/generated/**',
'**/.next/**',
'**/e2e/**',
'**/playwright-report/**',
'**/test-results/**',
'eslint.config.mjs',
'**/*.mjs',
],
Expand Down
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading