Skip to content
Closed
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 });
}
2 changes: 1 addition & 1 deletion apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
7 changes: 6 additions & 1 deletion 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 @@ -21,17 +23,20 @@
"@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",
"react": "^19.2.4",
"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"
}
Expand Down
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,
});
2 changes: 2 additions & 0 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -13,6 +14,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" className="dark">
<body>
<Providers>{children}</Providers>
<Analytics />
</body>
</html>
);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +24,7 @@ import { Withdraw } from '@/components/instructions/Withdraw';
type InstructionId =
| 'createEscrow'
| 'updateAdmin'
| 'setImmutable'
| 'allowMint'
| 'blockMint'
| 'addTimelock'
Expand All @@ -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' },
],
Expand Down Expand Up @@ -70,6 +73,7 @@ const NAV: {
const PANELS: Record<InstructionId, { title: string; component: React.ComponentType }> = {
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 },
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/components/instructions/SetImmutable.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<form
onSubmit={e => {
void handleSubmit(e);
}}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}
>
<div>
<Badge variant="warning">
This action is one-way. Escrow configuration becomes permanently immutable.
</Badge>
</div>
<FormField
label="Escrow Address"
value={escrow}
onChange={setEscrow}
autoFillValue={defaultEscrow}
onAutoFill={setEscrow}
placeholder="Escrowae7..."
required
/>
<SendButton sending={sending} />
<TxResult signature={signature} error={formError ?? error} />
</form>
);
}
Loading
Loading