diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56c7440..725ae6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,17 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm format:check - run: pnpm build + + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + + - name: Run Playwright E2E tests + run: pnpm test:e2e + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 10d89a7..73b0c77 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,13 @@ dist/ .env.* reference/ *.local + +# Playwright +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ package-lock.json playwright-report/ test-results/ -public/mockServiceWorker.js \ No newline at end of file +public/mockServiceWorker.js diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..41e06a7 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,125 @@ +# Playwright E2E Test Suite + +This directory contains the end-to-end (E2E) integration test suite for the Wraith Protocol Demo app, specifically focusing on Stellar stealth payments. + +## Directory Structure + +``` +e2e/ +├── fixtures.ts # Shared test fixtures (mocked Freighter, mocked Horizon, mocked Soroban RPC) +├── stellar.spec.ts # Stellar E2E scenarios +└── README.md # This documentation +``` + +## Running Tests + +First, install the Playwright browser binaries: + +```bash +pnpm playwright install +``` + +### Run all tests (headless, all browsers) + +```bash +pnpm test:e2e +``` + +### Run tests in interactive UI mode + +This starts the Playwright test runner UI, enabling step-by-step visual debugging: + +```bash +pnpm test:e2e:ui +``` + +### Debug a specific test + +```bash +pnpm playwright test e2e/stellar.spec.ts --project=chromium --debug +``` + +--- + +## How It Works (Mocking Architecture) + +Since interacting with real wallet extensions (like Freighter) and testnet contracts can be slow and unpredictable, we use a completely local, deterministic mocking architecture defined in `e2e/fixtures.ts`. + +### 1. Mocking Freighter Wallet (`freighter` fixture) + +The E2E suite updates `src/context/StellarWalletContext.tsx` to check for `window.freighterMock`. If present, it uses this mock instead of dynamically importing `@stellar/freighter-api`. + +In your spec, configure the mock wallet: + +```typescript +await freighter.mock({ + isConnected: true, + address: 'GDTESTING...', + signedMessage: '...', // Mock signature payload (base64) +}); +``` + +### 2. Mocking Network Calls (`horizon` fixture) + +The `horizon` fixture intercepts HTTP requests made by the page: + +- `GET https://horizon-testnet.stellar.org/accounts/*` maps to your configured balance/sequence. +- `POST https://horizon-testnet.stellar.org/transactions` returns successful or failed transaction responses. +- `POST https://soroban-testnet.stellar.org` intercepts Soroban JSON-RPC calls, resolving contract states and simulate/send/get transactions. + +In your spec: + +```typescript +await horizon.mock({ + accountExists: true, + accountBalance: '500', + txSuccess: true, + txHash: 'tx_hash_123', +}); +``` + +To mock custom contract announcements (e.g. stealth address events), pass them under `sorobanEvents`: + +```typescript +await horizon.mock({ + sorobanEvents: [ + { + schemeId: 1, + stealthAddress: 'GDQ...', + caller: 'GDT...', + ephemeralPubKey: new Uint8Array(32), + viewTag: 123, + }, + ], +}); +``` + +--- + +## How to Add a New Spec + +1. **Create a spec file**: Add a new file `e2e/your-feature.spec.ts`. +2. **Import test and expect**: Use the custom fixtures: + ```typescript + import { test, expect } from './fixtures'; + ``` +3. **Write the test structure**: + + ```typescript + test.describe('My Feature E2E Suite', () => { + test('Scenario title', async ({ page, freighter, horizon }) => { + // 1. Mock wallet and network state + await freighter.mock({ isConnected: true }); + await horizon.mock({ accountBalance: '100' }); + + // 2. Perform page interactions + await page.goto('/send'); + // ... + + // 3. Assert states + await expect(page.getByText('Success')).toBeVisible(); + }); + }); + ``` + +4. **Register or run**: Playwright automatically finds any files ending in `.spec.ts` in the `e2e/` folder. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..dad9455 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,290 @@ +import { test as base } from '@playwright/test'; +import { xdr, Address, nativeToScVal } from '@stellar/stellar-sdk'; + +export interface FreighterMockConfig { + isConnected: boolean; + address: string | null; + signedMessage: string | null; // base64 + signedTxXdr: string | null; + shouldFailConnect?: boolean; + shouldFailSignMessage?: boolean; + shouldFailSignTx?: boolean; + autoConnect?: boolean; +} + +export interface HorizonMockConfig { + accountExists: boolean; + accountBalance: string; + txSuccess: boolean; + txHash?: string; + txErrorCode?: string; + sorobanEvents?: Array<{ + schemeId: number; + stealthAddress: string; + caller: string; + ephemeralPubKey: Uint8Array; + viewTag: number; + }>; + sorobanSimulateSuccess?: boolean; + sorobanSimulateError?: string; + sorobanTxStatus?: string; + address?: string; +} + +const DEFAULT_WALLET_ADDRESS = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + +export const test = base.extend<{ + freighter: { + mock: (config: Partial) => Promise; + }; + horizon: { + mock: (config: Partial) => Promise; + }; +}>({ + freighter: async ({ page }, use) => { + const mock = async (config: Partial) => { + await page.addInitScript((cfg) => { + (window as any).freighterMock = { + isConnected: async () => ({ isConnected: cfg.isConnected !== false }), + authorized: false, + getAddress: async function () { + if (cfg.shouldFailConnect) { + return { address: '', error: 'Access denied' }; + } + if (this.authorized || cfg.autoConnect) { + return { + address: cfg.address || 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX', + }; + } + return { address: '' }; + }, + requestAccess: async function () { + if (cfg.shouldFailConnect) { + throw new Error('User rejected connection'); + } + this.authorized = true; + return { + address: cfg.address || 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX', + }; + }, + signMessage: async (message: string) => { + if (cfg.shouldFailSignMessage) { + throw new Error('User rejected signature'); + } + const defaultSig = + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + return { + signedMessage: cfg.signedMessage || defaultSig, + }; + }, + signTransaction: async (xdrString: string) => { + if (cfg.shouldFailSignTx) { + throw new Error('User rejected transaction signing'); + } + return { + signedTxXdr: cfg.signedTxXdr || xdrString, + }; + }, + }; + }, config); + }; + await use({ mock }); + }, + + horizon: async ({ page }, use) => { + const mock = async (config: Partial) => { + const mergedConfig = { + accountExists: true, + accountBalance: '1000', + txSuccess: true, + txHash: 'mocked_tx_hash_1234567890', + txErrorCode: '', + sorobanEvents: [], + sorobanSimulateSuccess: true, + sorobanSimulateError: '', + sorobanTxStatus: 'SUCCESS', + ...config, + }; + + // Set up in-page variables for Soroban Mock Server and Scanning + await page.addInitScript((cfg) => { + (window as any).sorobanServerMock = { + getAccount: async (address: string) => { + if ( + address !== 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX' && + address !== cfg.address && + !cfg.accountExists + ) { + return Promise.reject({ + code: 404, + message: `Account not found: ${address}`, + }); + } + return { + accountId: () => address, + sequenceNumber: () => '1', + }; + }, + simulateTransaction: async (tx: any) => { + if (!cfg.sorobanSimulateSuccess) { + return { error: cfg.sorobanSimulateError || 'Simulation failed' }; + } + return { + results: [{ auth: [], retval: { type: 'void' } }], + minResourceFee: '100', + transactionData: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + }; + }, + sendTransaction: async (tx: any) => { + return { + status: 'PENDING', + hash: cfg.txHash || 'mocked_soroban_tx_hash', + }; + }, + getTransaction: async (hash: string) => { + return { + status: cfg.sorobanTxStatus || 'SUCCESS', + hash, + }; + }, + }; + + (window as any).scanAnnouncementsMock = ( + announcements: any[], + viewingKey: any, + spendingPubKey: any, + spendingScalar: any, + ) => { + if (cfg.sorobanEvents && cfg.sorobanEvents.length > 0) { + return cfg.sorobanEvents.map((e) => ({ + stealthAddress: e.stealthAddress, + stealthPrivateScalar: 123456789n, + stealthPubKeyBytes: new Uint8Array([ + 23, 255, 173, 128, 104, 220, 13, 233, 147, 93, 54, 99, 111, 58, 209, 181, 222, 109, + 227, 65, 59, 18, 56, 142, 69, 59, 5, 242, 164, 193, 211, 219, + ]), + })); + } + return []; + }; + }, mergedConfig); + + // Route Horizon accounts calls + await page.route('https://horizon-testnet.stellar.org/accounts/*', async (route) => { + const url = route.request().url(); + const address = url.split('/accounts/').pop()?.split('?')[0] || ''; + + const isSender = address === DEFAULT_WALLET_ADDRESS || address === config.address; + + if (isSender || mergedConfig.accountExists) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: address, + sequence: '1', + balances: [{ asset_type: 'native', balance: mergedConfig.accountBalance }], + subentry_count: 0, + }), + }); + } else { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ + title: 'Resource Missing', + status: 404, + }), + }); + } + }); + + // Route Horizon transactions submission + await page.route('https://horizon-testnet.stellar.org/transactions', async (route) => { + if (route.request().method() === 'POST') { + if (mergedConfig.txSuccess) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + hash: mergedConfig.txHash, + ledger: 100, + }), + }); + } else { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + title: 'Transaction Failed', + extras: { + result_codes: { + transaction: mergedConfig.txErrorCode || 'tx_failed', + }, + }, + }), + }); + } + } else { + await route.continue(); + } + }); + + // Map mock events from Node config to base64 JSON-RPC structure + const base64Events = (mergedConfig.sorobanEvents || []).map((e) => { + const schemeIdScVal = nativeToScVal(e.schemeId, { type: 'u32' }); + const stealthScVal = new Address(e.stealthAddress).toScVal(); + const valueVec = [ + new Address(e.caller).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(e.ephemeralPubKey)), + xdr.ScVal.scvBytes(Buffer.from([e.viewTag])), + ]; + const valueScVal = xdr.ScVal.scvVec(valueVec); + + return { + topic: [ + xdr.ScVal.scvSymbol('announce').toXDR('base64'), + schemeIdScVal.toXDR('base64'), + stealthScVal.toXDR('base64'), + ], + value: valueScVal.toXDR('base64'), + contractId: 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL', + }; + }); + + // Route Soroban RPC calls (specifically for getEvents) + await page.route('https://soroban-testnet.stellar.org', async (route) => { + if (route.request().method() === 'POST') { + const body = route.request().postDataJSON(); + const id = body?.id || 1; + + if (body?.method === 'getEvents') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + jsonrpc: '2.0', + id, + result: { + events: base64Events, + }, + }), + }); + } else { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ jsonrpc: '2.0', id, result: {} }), + }); + } + } else { + await route.continue(); + } + }); + }; + + await use({ mock }); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/stellar.spec.ts b/e2e/stellar.spec.ts new file mode 100644 index 0000000..4b0064b --- /dev/null +++ b/e2e/stellar.spec.ts @@ -0,0 +1,375 @@ +import { test, expect } from './fixtures'; + +test.describe('Stellar Stealth Payments E2E Suite', () => { + test.beforeEach(async ({ page, context }) => { + page.on('console', (msg) => { + if (msg.type() === 'error') { + console.error('BROWSER ERROR:', msg.text()); + } else { + console.log('BROWSER LOG:', msg.text()); + } + }); + + // Grant clipboard permissions for copy-paste tests if supported (only Chromium supports this) + try { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + } catch { + // Ignored for non-chromium browsers + } + }); + + test('Wallet - 1. Shows Connect Freighter button by default when Stellar chain is selected', async ({ + page, + freighter, + }) => { + await freighter.mock({ isConnected: false }); + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + + // Verify correct page state when disconnected + await expect(page.locator('h1')).toHaveText('Send'); + await expect( + page.getByText('Connect your Freighter wallet to send stealth payments on Stellar.'), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Connect Freighter' })).toBeVisible(); + }); + + test('Wallet - 2. Shows installation error if Freighter wallet is not installed', async ({ + page, + freighter, + }) => { + // Mock wallet NOT installed + await freighter.mock({ isConnected: false }); + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Verify error message is shown + await expect( + page.getByText('Freighter wallet not found. Please install the Freighter browser extension.'), + ).toBeVisible(); + }); + + test('Wallet - 3. Handles user rejecting connection request', async ({ page, freighter }) => { + // Mock installed, but connection will fail + await freighter.mock({ isConnected: true, shouldFailConnect: true }); + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Verify error from wallet + await expect(page.getByText('User rejected connection')).toBeVisible(); + }); + + test('Wallet - 4. Successfully connects and triggers auto-signing key derivation', async ({ + page, + freighter, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ + isConnected: true, + address: mockAddress, + }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Header should show connected wallet address (truncated: GCDU...2XHX) + await expect(page.getByRole('button', { name: 'GCDU...2XHX' })).toBeVisible(); + + // Send form should now be visible since wallet is connected + await expect(page.getByLabel('Recipient Meta-Address')).toBeVisible(); + }); + + test('Wallet - 5. Disconnects wallet correctly when address button is clicked', async ({ + page, + freighter, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Click on the connected button (displays address) to disconnect + await page.getByRole('button', { name: 'GCDU...2XHX' }).click(); + + // Should return to disconnected state + await expect(page.getByRole('button', { name: 'Connect Freighter' })).toBeVisible(); + }); + + test('Send - 6. Validates empty input fields and keeps Send button disabled', async ({ + page, + freighter, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + const sendBtn = page.getByRole('button', { name: 'Send Privately' }); + await expect(sendBtn).toBeDisabled(); + + // Fill only recipient + await page.getByPlaceholder('st:xlm:...').fill('st:xlm:someaddress'); + await expect(sendBtn).toBeDisabled(); + + // Clear recipient, fill only amount + await page.getByPlaceholder('st:xlm:...').clear(); + await page.getByPlaceholder('0.0').fill('10.5'); + await expect(sendBtn).toBeDisabled(); + }); + + test('Send - 7. Validates recipient address format prefix', async ({ page, freighter }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Enter wrong prefix + await page.getByPlaceholder('st:xlm:...').fill('st:eth:invalidprefixaddress'); + await page.getByPlaceholder('0.0').fill('10'); + await page.getByRole('button', { name: 'Send Privately' }).click(); + + await expect(page.getByText('Enter a valid Stellar meta-address (st:xlm:...)')).toBeVisible(); + }); + + test('Send - 8. Successfully executes payment to a new stealth address (creating the account)', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + const recipientMetaAddress = + 'st:xlm:5a1922b5614eed2ef72ebad40abc5d014f7c27b6e1de5dc36976e9eec4cbe29e6b912a495f9f14513d54a00a7887f986d394a30a77239475caf211e8094b6cdb'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + // Mock stealth address NOT existing so it creates the account + await horizon.mock({ + accountExists: false, + txSuccess: true, + txHash: 'tx_success_create_account', + }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByPlaceholder('st:xlm:...').fill(recipientMetaAddress); + await page.getByPlaceholder('0.0').fill('50'); + await page.getByRole('button', { name: 'Send Privately' }).click(); + + // Success screen details + await expect(page.getByText('Transfer Complete')).toBeVisible(); + await expect(page.getByText('tx_success_create_account')).toBeVisible(); + await expect(page.getByRole('button', { name: 'New Transfer' })).toBeVisible(); + }); + + test('Send - 9. Successfully executes payment to an existing stealth address (regular payment)', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + const recipientMetaAddress = + 'st:xlm:5a1922b5614eed2ef72ebad40abc5d014f7c27b6e1de5dc36976e9eec4cbe29e6b912a495f9f14513d54a00a7887f986d394a30a77239475caf211e8094b6cdb'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + // Mock stealth address ALREADY existing, so it performs a payment + await horizon.mock({ + accountExists: true, + accountBalance: '20', + txSuccess: true, + txHash: 'tx_success_payment', + }); + + await page.goto('/send'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByPlaceholder('st:xlm:...').fill(recipientMetaAddress); + await page.getByPlaceholder('0.0').fill('5'); + await page.getByRole('button', { name: 'Send Privately' }).click(); + + await expect(page.getByText('Transfer Complete')).toBeVisible(); + await expect(page.getByText('tx_success_payment')).toBeVisible(); + }); + + test('Receive - 10. Automatically derives keys and shows meta-address on wallet connection', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + await horizon.mock({}); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Verify stealth meta-address card is visible + await expect(page.getByText('Your Stealth Meta-Address')).toBeVisible(); + // Verify it starts with st:xlm: prefix + const metaAddressElement = page.locator('code').first(); + await expect(metaAddressElement).toContainText('st:xlm:'); + }); + + test('Receive - 11. Copies the derived stealth meta-address to clipboard', async ({ + page, + freighter, + horizon, + browserName, + }) => { + // Clipboard reading via navigator.clipboard API is only standard/supported on Chromium in headless E2E + if (browserName !== 'chromium') { + test.skip(); + } + + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + await horizon.mock({}); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Click the copy button + await page.getByRole('button', { name: 'Copy' }).first().click(); + + // Verify clipboard content + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toContain('st:xlm:'); + }); + + test('Receive - 12. Registers derived stealth keys on-chain', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + // Mock as NOT registered initially, registration simulation succeeds + await horizon.mock({ accountExists: true, txSuccess: true, txHash: 'tx_register_hash' }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + // Click Register On-Chain + await page.getByRole('button', { name: 'Register On-Chain' }).click(); + + // Verify registered indicator shows up + await expect(page.getByText('Meta-address registered on-chain')).toBeVisible(); + }); + + test('Receive - 13. Reports "No transfers found" when scanning yields empty events list', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + await freighter.mock({ isConnected: true, address: mockAddress }); + // Empty events list + await horizon.mock({ sorobanEvents: [] }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByRole('button', { name: 'Scan for Payments' }).click(); + + await expect(page.getByText('No transfers found')).toBeVisible(); + await expect(page.getByText('No stealth transfers matched your keys.')).toBeVisible(); + }); + + test('Receive - 14. Successfully scans, displays matched payment transfers, and reveals keys', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + // Matched stealth address details (must be a valid Stellar public key) + const matchedStealthAddress = 'GAL77LMANDOA32MTLU3GG3Z22G2543PDIE5REOEOIU5QL4VEYHJ5WKON'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + + // Mock a Soroban Event matched to this viewing key + await horizon.mock({ + accountExists: true, + accountBalance: '100', + sorobanEvents: [ + { + schemeId: 1, + stealthAddress: matchedStealthAddress, + caller: mockAddress, + ephemeralPubKey: new Uint8Array(32), // dummy 32-byte ephemeral key + viewTag: 42, + }, + ], + }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByRole('button', { name: 'Scan for Payments' }).click(); + + // Verify detected payment + await expect(page.getByText('1 transfer found')).toBeVisible(); + await expect(page.getByText(matchedStealthAddress)).toBeVisible(); + await expect(page.getByText('100 XLM')).toBeVisible(); + + // Click "Reveal secret key" + await page.getByRole('button', { name: 'Reveal secret key' }).click(); + await expect(page.getByText('Stealth Key', { exact: true })).toBeVisible(); + }); + + test('Receive - 15. Successfully withdraws funds from a detected stealth payment', async ({ + page, + freighter, + horizon, + }) => { + const mockAddress = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + const matchedStealthAddress = 'GAL77LMANDOA32MTLU3GG3Z22G2543PDIE5REOEOIU5QL4VEYHJ5WKON'; + + await freighter.mock({ isConnected: true, address: mockAddress }); + await horizon.mock({ + accountExists: true, + accountBalance: '250', + txSuccess: true, + txHash: 'tx_withdrawal_hash_987654', + sorobanEvents: [ + { + schemeId: 1, + stealthAddress: matchedStealthAddress, + caller: mockAddress, + ephemeralPubKey: new Uint8Array(32), + viewTag: 123, + }, + ], + }); + + await page.goto('/receive'); + await page.selectOption('select', 'stellar'); + await page.getByRole('button', { name: 'Connect Freighter' }).click(); + + await page.getByRole('button', { name: 'Scan for Payments' }).click(); + + // Fill destination and withdraw + await page.getByPlaceholder('Destination address (G...)').fill(mockAddress); + await page.getByRole('button', { name: 'Withdraw' }).click(); + + // Confirm withdrawal transaction success + await expect(page.getByText('Withdrawn —')).toBeVisible(); + await expect(page.getByText('tx_withdrawal')).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 754e1ba..af6d520 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" "test": "playwright test", "test:ui": "playwright test --ui", "storybook": "storybook dev -p 6006", @@ -47,6 +49,7 @@ "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", "@playwright/test": "^1.61.0", + "@types/node": "^26.0.0", "@storybook/addon-a11y": "^8", "@storybook/addon-essentials": "^8", "@storybook/addon-interactions": "^8", diff --git a/playwright.config.ts b/playwright.config.ts index 9301a48..8bd25b1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,32 @@ import { defineConfig, devices } from '@playwright/test'; +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only to track and budget for flakes */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:5173', + + /* Collect trace when retrying a failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ export default defineConfig({ testDir: '.', testMatch: ['**/tests/**/*.spec.ts', '**/e2e/**/*.spec.ts'], @@ -17,6 +44,22 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm dev', + url: 'http://localhost:5173', ], webServer: { command: 'npm run dev', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e9ea83..88ba3c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,14 +86,16 @@ importers: devDependencies: '@commitlint/cli': specifier: ^19.0.0 - version: 19.8.1(@types/node@25.9.4)(typescript@5.9.3) + version: 19.8.1(@types/node@26.0.0)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^19.0.0 version: 19.8.1 '@playwright/test': specifier: ^1.61.0 - specifier: ^1.60.0 - version: 1.61.1 + version: 1.61.0 + '@types/node': + specifier: ^26.0.0 + version: 26.0.0 '@storybook/addon-a11y': specifier: ^8 version: 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6)) @@ -1094,6 +1096,10 @@ packages: resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr' + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2046,6 +2052,11 @@ packages: peerDependencies: storybook: ^8.6.14 + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} '@storybook/csf-plugin@8.6.18': resolution: {integrity: sha512-x1ioz/L0CwaelCkHci3P31YtvwayN3FBftvwQOPbvRh9qeb4Cpz5IdVDmyvSxxYwXN66uAORNoqgjTi7B4/y5Q==} peerDependencies: @@ -4918,7 +4929,8 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: + onetime@5.1.2:105 + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -5119,6 +5131,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + pixelmatch@5.3.0: resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} hasBin: true @@ -6077,6 +6099,9 @@ packages: undici-types@7.25.0: resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -7061,11 +7086,11 @@ snapshots: - utf-8-validate - zod - '@commitlint/cli@19.8.1(@types/node@25.9.4)(typescript@5.9.3)': + '@commitlint/cli@19.8.1(@types/node@26.0.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 19.8.1 '@commitlint/lint': 19.8.1 - '@commitlint/load': 19.8.1(@types/node@25.9.4)(typescript@5.9.3) + '@commitlint/load': 19.8.1(@types/node@26.0.0)(typescript@5.9.3) '@commitlint/read': 19.8.1 '@commitlint/types': 19.8.1 tinyexec: 1.1.1 @@ -7112,7 +7137,7 @@ snapshots: '@commitlint/rules': 19.8.1 '@commitlint/types': 19.8.1 - '@commitlint/load@19.8.1(@types/node@25.9.4)(typescript@5.9.3)': + '@commitlint/load@19.8.1(@types/node@26.0.0)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 19.8.1 '@commitlint/execute-rule': 19.8.1 @@ -7120,7 +7145,7 @@ snapshots: '@commitlint/types': 19.8.1 chalk: 5.6.2 cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.9.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@26.0.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -7885,6 +7910,9 @@ snapshots: '@paulmillr/qr@0.2.1': {} + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 '@pkgjs/parseargs@0.11.0': optional: true @@ -9172,11 +9200,13 @@ snapshots: '@storybook/addon-highlight@8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))': dependencies: + '@types/node': 26.0.0 '@storybook/global': 5.0.0 storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6) '@storybook/addon-interactions@8.6.14(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))': dependencies: + '@types/node': 26.0.0 '@storybook/global': 5.0.0 '@storybook/instrumenter': 8.6.14(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6)) '@storybook/test': 8.6.14(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6)) @@ -9256,6 +9286,11 @@ snapshots: storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6) unplugin: 1.16.1 + '@types/node@26.0.0': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': '@storybook/csf-plugin@8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6))': dependencies: storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.2)(utf-8-validate@6.0.6) @@ -9627,11 +9662,11 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 25.9.4 + '@types/node': 26.0.0 '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.4 + '@types/node': 26.0.0 '@types/yargs-parser@21.0.3': {} @@ -9666,7 +9701,7 @@ snapshots: dependencies: '@vanilla-extract/css': 1.17.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.4)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -9674,7 +9709,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@25.9.4)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) + vite: 6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -11106,11 +11141,12 @@ snapshots: core-util-is@1.0.3: {} + cosmiconfig-typescript-loader@6.3.0(@types/node@26.0.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): corser@2.0.1: {} cosmiconfig-typescript-loader@6.3.0(@types/node@25.9.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 25.9.4 + '@types/node': 26.0.0 cosmiconfig: 9.0.1(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -13502,6 +13538,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + pixelmatch@5.3.0: dependencies: pngjs: 6.0.0 @@ -14494,6 +14538,8 @@ snapshots: undici-types@7.25.0: {} + undici-types@8.3.0: {} + unicorn-magic@0.1.0: {} union@0.5.0: @@ -14669,7 +14715,7 @@ snapshots: - utf-8-validate - zod - vite@6.4.2(@types/node@25.9.4)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3): + vite@6.4.2(@types/node@26.0.0)(jiti@1.21.7)(terser@5.46.1)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -14678,7 +14724,7 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.9.4 + '@types/node': 26.0.0 fsevents: 2.3.3 jiti: 1.21.7 terser: 5.46.1 diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index ca0a35f..05081b4 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -36,6 +36,116 @@ import { KeyVault } from '@/vault'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; +async function fetchAnnouncementEvents( + rpcUrl: string, + contractId: string, +): Promise { + const all: Announcement[] = []; + + try { + let startLedger = 1; + const probeRes = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'getEvents', + params: { + startLedger: 1, + filters: [{ type: 'contract', contractIds: [contractId] }], + pagination: { limit: 1 }, + }, + }), + }); + const probeData = await probeRes.json(); + + if (probeData.error?.message) { + const match = probeData.error.message.match(/range:\s*(\d+)\s*-\s*(\d+)/); + if (match) { + const oldest = parseInt(match[1], 10); + const latest = parseInt(match[2], 10); + startLedger = Math.max(oldest, latest - 5000); + } else { + return all; + } + } + + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + const params: Record = { + filters: [{ type: 'contract', contractIds: [contractId] }], + pagination: { limit: 1000 }, + }; + + if (cursor) { + (params.pagination as Record).cursor = cursor; + } else { + params.startLedger = startLedger; + } + + const res = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'getEvents', params }), + }); + + const data = await res.json(); + const events = data.result?.events ?? []; + + for (const event of events) { + try { + const ann = parseAnnouncementEvent(event); + if (ann) all.push(ann); + } catch (err) { + console.error('Failed to parse announcement event:', err); + } + } + + if (events.length < 1000) { + hasMore = false; + } else { + cursor = data.result?.cursor; + if (!cursor) hasMore = false; + } + } + } catch { + // Events API may not be available + } + + return all; +} + +function parseAnnouncementEvent(event: Record): Announcement | null { + const topics = event.topic as string[]; + if (!topics || topics.length < 3) return null; + + const schemeIdScVal = xdr.ScVal.fromXDR(topics[1], 'base64'); + const schemeId = schemeIdScVal.u32(); + + const stealthScVal = xdr.ScVal.fromXDR(topics[2], 'base64'); + const stealthScAddress = stealthScVal.address(); + const stealthAddress = Address.fromScAddress(stealthScAddress).toString(); + + const valueScVal = xdr.ScVal.fromXDR(event.value as string, 'base64'); + const valueVec = valueScVal.vec(); + if (!valueVec || valueVec.length < 3) return null; + + const callerScAddress = valueVec[0].address(); + const caller = Address.fromScAddress(callerScAddress).toString(); + + const ephBytes = valueVec[1].bytes(); + const ephemeralPubKey = bytesToHex(new Uint8Array(ephBytes)); + + const metaBytes = valueVec[2].bytes(); + const metadata = bytesToHex(new Uint8Array(metaBytes)); + + return { schemeId, stealthAddress, caller, ephemeralPubKey, metadata }; +} + +function StellarStealthRow({ function StellarMatchCardContainer({ match, onWithdrawn, @@ -214,6 +324,74 @@ function StellarMatchCardContainer({ setWithdrawing(true); setShowSponsorPrompt(false); + {!withdrawHash && balance && parseFloat(balance) > 0 && ( +
+ +
+ setDest(e.target.value)} + placeholder="Destination address (G...)" + className="h-10 flex-1 border border-outline-variant bg-surface px-3 font-mono text-xs text-primary placeholder:text-outline focus:border-primary" + /> + +
+
+ )} + + {error &&

{error}

} + + {withdrawHash && ( +
+ + + Withdrawn —{' '} + + {withdrawHash.slice(0, 14)}... + + +
+ )} + +
+ {!showKey ? ( + + ) : ( +
+
+ + Stealth Key + + +
+ {scalarHex} +
+ )} +
+ const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); try { @@ -437,7 +615,8 @@ export function StellarReceive() { (async () => { try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); - const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); + const soroban = + (window as any).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const networkPassphrase = STELLAR_NETWORK.networkPassphrase; const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); @@ -684,7 +863,8 @@ export function StellarReceive() { const onRetryReg = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); - const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); + const soroban = + (window as any).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const networkPassphrase = STELLAR_NETWORK.networkPassphrase; const accountResponse = await withRetry(() => soroban.getAccount(address), { onRetry: onRetryReg }); @@ -782,6 +962,16 @@ export function StellarReceive() { setError(''); try { + const announcements = await fetchAnnouncementEvents( + STELLAR_NETWORK.rpcUrl, + ANNOUNCER_CONTRACT, + ); + const scanFn = (window as any).scanAnnouncementsMock || scanAnnouncements; + const results = scanFn( + announcements, + stellarKeys.viewingKey, + stellarKeys.spendingPubKey, + stellarKeys.spendingScalar, if (workerRef.current) { workerRef.current.terminate(); } diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index b17ff2a..b625e50 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -488,7 +488,8 @@ export function StellarSend() { // Announce via Soroban (best-effort) try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); - const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); + const soroban = + (window as any).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const announcerContract = new Contract(ANNOUNCER_CONTRACT); const freshRes = await fetchWithRetry(`${horizonUrl}/accounts/${address}`, {}, { onRetry }); @@ -575,6 +576,158 @@ export function StellarSend() { (sourceBalance !== null ? `${formatXlm(sourceBalance)} XLM` : 'Enter amount'); return ( +
+
+ + Stellar Testnet / XLM + +

+ Send +

+

+ Send XLM privately using stealth addresses. The recipient gets funds at a fresh address + only they can control. +

+
+ + {!stealthResult && ( +
+
+ +
+ setRecipient(e.target.value)} + placeholder="st:xlm:..." + className="h-12 w-full border border-outline-variant bg-surface px-4 pr-20 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" + /> + +
+
+ +
+ +
+ setAmount(e.target.value)} + placeholder="0.0" + className="h-12 w-full border border-outline-variant bg-surface px-4 pr-16 font-heading text-2xl text-primary placeholder:text-outline focus:border-primary" + /> + + XLM + +
+
+ +
+
+ + Network fee + + 100 stroops +
+
+ + Announcer contract + + Soroban +
+
+ + {error &&

{error}

} + + +
+ )} + + {stealthResult && ( +
+
+ {isSuccess ? ( + + ) : ( + + )} + + {isSuccess ? 'Transfer Complete' : 'Pending'} + +
+ +
+
+ + Stealth Address + + +
+ + {txHash && ( +
+ + Transaction Hash + + +
+ )} +
+ + {isSuccess && ( + + )} +
+ )} +
(null); const { address, isConnected, isInstalled, isNetworkMismatch, connect, disconnect } = useStellarWallet(); const [isConnecting, setIsConnecting] = useState(false); @@ -72,20 +74,28 @@ function FreighterButton() { : 'disconnected'; return ( - <> +
- - + {error && {error}} +
+ ); } diff --git a/src/context/StellarWalletContext.tsx b/src/context/StellarWalletContext.tsx index 58f1ecd..8db8d2c 100644 --- a/src/context/StellarWalletContext.tsx +++ b/src/context/StellarWalletContext.tsx @@ -24,6 +24,13 @@ interface StellarWalletContextValue { export const StellarWalletContext = createContext(null); +async function getFreighter() { + if (typeof window !== 'undefined' && (window as any).freighterMock) { + return (window as any).freighterMock; + } + return await import('@stellar/freighter-api'); +} + export function StellarWalletProvider({ children }: { children: React.ReactNode }) { const stellarWallet = useStellarWalletHook(); @@ -110,7 +117,7 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode const tryInit = async () => { if (stopped) return; try { - const freighter = await import('@stellar/freighter-api'); + const freighter = await getFreighter(); const { isConnected: connected } = await freighter.isConnected(); if (!connected) { @@ -207,7 +214,7 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode }, []); const connect = useCallback(async () => { - const freighter = await import('@stellar/freighter-api'); + const freighter = await getFreighter(); const { isConnected: connected } = await freighter.isConnected(); if (!connected) { throw new Error( @@ -255,7 +262,7 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode async (message: string): Promise => { if (!address) throw new Error('Wallet not connected'); - const freighter = await import('@stellar/freighter-api'); + const freighter = await getFreighter(); const { signedMessage } = await freighter.signMessage(message, { address, networkPassphrase: STELLAR_NETWORK.networkPassphrase, @@ -298,7 +305,7 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode async (xdr: string): Promise => { if (!address) throw new Error('Wallet not connected'); - const freighter = await import('@stellar/freighter-api'); + const freighter = await getFreighter(); const { signedTxXdr } = await freighter.signTransaction(xdr, { address, networkPassphrase: STELLAR_NETWORK.networkPassphrase,