diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml new file mode 100644 index 00000000..3490f024 --- /dev/null +++ b/.github/workflows/frontend-e2e.yml @@ -0,0 +1,48 @@ +name: Frontend E2E + +on: + pull_request: + paths: + - 'frontend/**' + - '.github/workflows/frontend-e2e.yml' + push: + branches: + - main + - master + paths: + - 'frontend/**' + - '.github/workflows/frontend-e2e.yml' + +jobs: + playwright: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run Playwright tests + run: npm run test:e2e + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 diff --git a/frontend/e2e/fixtures/web3.fixture.ts b/frontend/e2e/fixtures/web3.fixture.ts new file mode 100644 index 00000000..a65dfba0 --- /dev/null +++ b/frontend/e2e/fixtures/web3.fixture.ts @@ -0,0 +1,176 @@ +import { test as base, expect, type BrowserContext } from '@playwright/test'; + +const STELLAR_TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015'; +const MOCK_STELLAR_ADDRESS = 'GCMOCKWALLETADDRESS000000000000000000000000000000000000000000000'; +const MOCK_ETHEREUM_ADDRESS = '0x1111111111111111111111111111111111111111'; + +type Web3Mocks = { + stellarAddress: string; + ethereumAddress: string; + signedTransactionXdr: string; + installWalletMocks: () => Promise; + installWebSocketMock: () => Promise; +}; + +export const test = base.extend({ + stellarAddress: async ({}, use) => { + await use(MOCK_STELLAR_ADDRESS); + }, + ethereumAddress: async ({}, use) => { + await use(MOCK_ETHEREUM_ADDRESS); + }, + signedTransactionXdr: async ({}, use) => { + await use('AAAAAgAAAAA-web3-student-lab-signed-xdr'); + }, + installWalletMocks: async ({ context, stellarAddress, ethereumAddress, signedTransactionXdr }, use) => { + await installWalletMocks(context, { + stellarAddress, + ethereumAddress, + signedTransactionXdr, + }); + await use(async () => undefined); + }, + installWebSocketMock: async ({ context }, use) => { + await installWebSocketMock(context); + await use(async () => undefined); + }, +}); + +export { expect }; + +async function installWalletMocks( + context: BrowserContext, + options: { + stellarAddress: string; + ethereumAddress: string; + signedTransactionXdr: string; + } +) { + await context.addInitScript( + ({ stellarAddress, ethereumAddress, signedTransactionXdr, networkPassphrase }) => { + const freighterApi = { + isConnected: async () => ({ isConnected: true }), + requestAccess: async () => ({ address: stellarAddress }), + getAddress: async () => ({ address: stellarAddress }), + getNetwork: async () => ({ network: 'TESTNET', networkPassphrase }), + getNetworkDetails: async () => ({ + network: 'TESTNET', + networkUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase, + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + }), + signTransaction: async (xdr: string) => ({ + signedTxXdr: `${signedTransactionXdr}:${xdr.length}`, + signerAddress: stellarAddress, + }), + }; + + Object.defineProperty(window, 'freighterApi', { + configurable: true, + value: freighterApi, + }); + Object.defineProperty(window, 'freighter', { + configurable: true, + value: freighterApi, + }); + Object.defineProperty(window, 'stellar', { + configurable: true, + value: { freighter: freighterApi }, + }); + + Object.defineProperty(window, 'ethereum', { + configurable: true, + value: { + isMetaMask: true, + selectedAddress: ethereumAddress, + request: async ({ method }: { method: string; params?: unknown[] }) => { + if (method === 'eth_requestAccounts' || method === 'eth_accounts') { + return [ethereumAddress]; + } + if (method === 'personal_sign') { + return '0xmocked_personal_signature'; + } + if (method === 'eth_chainId') { + return '0xaa36a7'; + } + return null; + }, + on: () => undefined, + removeListener: () => undefined, + }, + }); + + Object.defineProperty(window, 'albedo', { + configurable: true, + value: { + publicKey: async () => ({ pubkey: stellarAddress }), + tx: async ({ xdr }: { xdr: string; network: string }) => ({ + signed_envelope_xdr: `${signedTransactionXdr}:albedo:${xdr.length}`, + }), + }, + }); + }, + { + stellarAddress: options.stellarAddress, + ethereumAddress: options.ethereumAddress, + signedTransactionXdr: options.signedTransactionXdr, + networkPassphrase: STELLAR_TESTNET_PASSPHRASE, + } + ); +} + +async function installWebSocketMock(context: BrowserContext) { + await context.addInitScript(() => { + class MockWebSocket extends EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + readonly url: string; + readonly protocol = ''; + readonly extensions = ''; + binaryType: BinaryType = 'blob'; + bufferedAmount = 0; + readyState = MockWebSocket.CONNECTING; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + + constructor(url: string) { + super(); + this.url = url; + window.setTimeout(() => { + this.readyState = MockWebSocket.OPEN; + const event = new Event('open'); + this.dispatchEvent(event); + this.onopen?.(event); + }, 0); + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + const payload = typeof data === 'string' ? data : '[binary]'; + window.setTimeout(() => { + const event = new MessageEvent('message', { + data: JSON.stringify({ type: 'ack', payload }), + }); + this.dispatchEvent(event); + this.onmessage?.(event); + }, 0); + } + + close(code = 1000, reason = 'mock closed') { + this.readyState = MockWebSocket.CLOSED; + const event = new CloseEvent('close', { code, reason, wasClean: true }); + this.dispatchEvent(event); + this.onclose?.(event); + } + } + + Object.defineProperty(window, 'WebSocket', { + configurable: true, + value: MockWebSocket, + }); + }); +} diff --git a/frontend/e2e/tests/navigation.spec.ts b/frontend/e2e/tests/navigation.spec.ts new file mode 100644 index 00000000..862f864c --- /dev/null +++ b/frontend/e2e/tests/navigation.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '../fixtures/web3.fixture'; + +test.describe('critical learning journeys', () => { + test('opens the simulator and playground with mocked realtime transport', async ({ + page, + installWebSocketMock, + }) => { + await installWebSocketMock(); + + await page.goto('/simulator', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: /Network Simulator/i })).toBeVisible(); + + await page.goto('/playground', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: /Soroban Playground/i })).toBeVisible(); + await expect(page.getByText(/Experimental Smart Contract Runtime/i)).toBeVisible(); + }); +}); diff --git a/frontend/e2e/tests/wallet.spec.ts b/frontend/e2e/tests/wallet.spec.ts new file mode 100644 index 00000000..dd123f77 --- /dev/null +++ b/frontend/e2e/tests/wallet.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '../fixtures/web3.fixture'; + +test.describe('wallet onboarding', () => { + test('installs mocked Stellar and Ethereum wallet providers', async ({ + page, + installWalletMocks, + stellarAddress, + ethereumAddress, + }) => { + await installWalletMocks(); + await page.goto('/auth/login', { waitUntil: 'domcontentloaded' }); + + const walletState = await page.evaluate(async () => { + const freighter = (window as any).freighterApi; + const ethereum = (window as any).ethereum; + + return { + stellarAddress: freighter ? (await freighter.requestAccess?.())?.address : null, + ethereumAddress: ethereum ? (await ethereum.request({ method: 'eth_requestAccounts' }))?.[0] : null, + }; + }); + + expect(walletState.stellarAddress).toBe(stellarAddress); + expect(walletState.ethereumAddress).toBe(ethereumAddress); + await expect(page.getByRole('button', { name: /Freighter/ })).toContainText('Ready to connect'); + }); + + test('restores wallet state into the transaction state chart', async ({ + page, + stellarAddress, + }) => { + await page.addInitScript( + ({ address }) => { + window.localStorage.setItem( + 'stellar_wallet', + JSON.stringify({ wallet: 'Freighter', pk: address }) + ); + }, + { address: stellarAddress } + ); + + await page.goto('/devtools/wallet', { waitUntil: 'domcontentloaded' }); + + await expect(page.getByText(`CONNECTED — Freighter`)).toBeVisible(); + await expect(page.getByText(stellarAddress).first()).toBeVisible(); + await expect(page.getByLabel('Web3 transaction lifecycle state chart')).toContainText('connected'); + }); + + test('keeps unavailable Freighter detection controlled', async ({ page }) => { + await page.goto('/auth/login', { waitUntil: 'domcontentloaded' }); + + await expect(page.getByRole('button', { name: /Freighter/ })).toContainText( + 'Click to detect extension' + ); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a23e5c2c..d4779f4c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -50,6 +50,7 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -5124,13 +5125,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -6269,10 +6263,7 @@ "gopd": "^1.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, "node_modules/delaunator": { @@ -6443,7 +6434,7 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8" } }, "node_modules/es-module-lexer": { @@ -6672,10 +6663,7 @@ "is-callable": "^1.2.7" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6.0.0" } }, "node_modules/form-data": { diff --git a/frontend/package.json b/frontend/package.json index 6dc11449..3c297a68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..803a0c01 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000); +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './e2e/tests', + timeout: 60_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : 1, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list'], ['html', { open: 'never' }]], + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: { + command: `npm run build && npm run start -- --hostname 127.0.0.1 --port ${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'mobile-chrome', + use: { ...devices['Pixel 7'] }, + }, + ], +}); diff --git a/frontend/src/app/devtools/wallet/page.tsx b/frontend/src/app/devtools/wallet/page.tsx index aad7fadf..a250b66c 100644 --- a/frontend/src/app/devtools/wallet/page.tsx +++ b/frontend/src/app/devtools/wallet/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useWallet, WALLET_PROVIDERS } from '@/contexts/WalletContext'; +import { TransactionStateChart } from '@/components/wallet/TransactionStateChart'; import { useState } from 'react'; function truncate(pk: string) { @@ -8,7 +9,16 @@ function truncate(pk: string) { } export default function WalletPage() { - const { publicKey, activeWallet, isConnecting, error, connect, disconnect } = useWallet(); + const { + publicKey, + activeWallet, + isConnecting, + error, + transactionState, + transactionContext, + connect, + disconnect, + } = useWallet(); const [showModal, setShowModal] = useState(false); const [connectError, setConnectError] = useState(null); @@ -90,6 +100,8 @@ export default function WalletPage() { ))} + + {/* Connect Modal */} diff --git a/frontend/src/components/wallet/TransactionStateChart.tsx b/frontend/src/components/wallet/TransactionStateChart.tsx new file mode 100644 index 00000000..1e149aa5 --- /dev/null +++ b/frontend/src/components/wallet/TransactionStateChart.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { + getWeb3TransactionStatus, + web3TransactionStateOrder, + type Web3TransactionContext, + type Web3TransactionStatus, +} from '@/lib/web3/transactionMachine'; + +const labels: Record = { + idle: 'Idle', + detectingWallet: 'Detecting', + connectingWallet: 'Connecting', + connected: 'Connected', + switchingNetwork: 'Network', + awaitingSignature: 'Signature', + submittingTransaction: 'Submitting', + confirmed: 'Confirmed', + failed: 'Failed', + disconnected: 'Disconnected', +}; + +interface TransactionStateChartProps { + state: unknown; + context: Web3TransactionContext; +} + +export function TransactionStateChart({ state, context }: TransactionStateChartProps) { + const activeState = getWeb3TransactionStatus(state); + + return ( +
+
+

+ Transaction State +

+ + {activeState} + +
+ +
+ {web3TransactionStateOrder.map((node) => { + const isActive = node === activeState; + const isFailure = node === 'failed'; + return ( +
+ {labels[node]} +
+ ); + })} +
+ +
+
+
Wallet
+
{context.walletName ?? 'none'}
+
+
+
Network
+
{context.network}
+
+
+
Public key
+
{context.publicKey ?? 'none'}
+
+ {context.error ? ( +
+
Error
+
{context.error}
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/contexts/WalletContext.tsx b/frontend/src/contexts/WalletContext.tsx index 7dac842e..64ac937b 100644 --- a/frontend/src/contexts/WalletContext.tsx +++ b/frontend/src/contexts/WalletContext.tsx @@ -6,7 +6,13 @@ import { requestAccess as requestFreighterAccess, signTransaction as signFreighterTransaction, } from '@stellar/freighter-api'; +import { useMachine } from '@xstate/react'; import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { + web3TransactionMachine, + type Web3TransactionContext, + type Web3TransactionStatus, +} from '@/lib/web3/transactionMachine'; // ─── Wallet Interface ───────────────────────────────────────────────────────── @@ -26,20 +32,35 @@ export interface WalletProvider { declare global { interface Window { freighter?: { - isConnected: () => Promise; - getPublicKey: () => Promise; - signTransaction: (xdr: string, opts?: object) => Promise; + isConnected: () => Promise; + requestAccess?: () => Promise<{ address: string; error?: string }>; + getAddress?: () => Promise<{ address: string; error?: string }>; + getPublicKey?: () => Promise; + signTransaction: ( + xdr: string, + opts?: object + ) => Promise; }; freighterApi?: { - isConnected?: () => Promise; - getPublicKey: () => Promise; - signTransaction: (xdr: string, opts?: object) => Promise; + isConnected?: () => Promise; + requestAccess?: () => Promise<{ address: string; error?: string }>; + getAddress?: () => Promise<{ address: string; error?: string }>; + getPublicKey?: () => Promise; + signTransaction: ( + xdr: string, + opts?: object + ) => Promise; }; stellar?: { freighter?: { - isConnected?: () => Promise; - getPublicKey: () => Promise; - signTransaction: (xdr: string, opts?: object) => Promise; + isConnected?: () => Promise; + requestAccess?: () => Promise<{ address: string; error?: string }>; + getAddress?: () => Promise<{ address: string; error?: string }>; + getPublicKey?: () => Promise; + signTransaction: ( + xdr: string, + opts?: object + ) => Promise; }; }; albedo?: { @@ -53,6 +74,55 @@ declare global { } } +const resolveInjectedFreighter = () => + typeof window === 'undefined' + ? null + : window.freighterApi || window.freighter || window.stellar?.freighter || null; + +const normalizeConnection = (connection: boolean | { isConnected: boolean; error?: string }) => { + if (typeof connection === 'boolean') { + return { isConnected: connection }; + } + return connection; +}; + +const getInjectedFreighterAddress = async () => { + const injected = resolveInjectedFreighter(); + if (!injected) { + return null; + } + if (injected.requestAccess) { + const access = await injected.requestAccess(); + if (access.error || !access.address) { + throw new Error(access.error || 'Freighter did not return an address'); + } + return access.address; + } + if (injected.getAddress) { + const address = await injected.getAddress(); + if (address.error || !address.address) { + throw new Error(address.error || 'Freighter did not return an address'); + } + return address.address; + } + return injected.getPublicKey?.() ?? null; +}; + +const signWithInjectedFreighter = async (xdr: string) => { + const injected = resolveInjectedFreighter(); + if (!injected?.signTransaction) { + return null; + } + const result = await injected.signTransaction(xdr); + if (typeof result === 'string') { + return result; + } + if (result.error || !result.signedTxXdr) { + throw new Error(result.error || 'Freighter could not sign the transaction'); + } + return result.signedTxXdr; +}; + const freighterAdapter: WalletProvider = { name: 'Freighter', icon: '🚀', @@ -60,7 +130,15 @@ const freighterAdapter: WalletProvider = { typeof window !== 'undefined' && (!!window.freighter || !!window.freighterApi || !!window.stellar?.freighter), connect: async () => { - const connection = await isFreighterConnected(); + const injected = resolveInjectedFreighter(); + if (!injected) { + throw new Error( + 'Freighter is not available to this page yet. Refresh the page and make sure the extension is enabled for this site.' + ); + } + const connection = normalizeConnection( + injected.isConnected ? await injected.isConnected() : await isFreighterConnected() + ); if (connection.error) { throw new Error(connection.error); } @@ -71,6 +149,11 @@ const freighterAdapter: WalletProvider = { ); } + const injectedAddress = await getInjectedFreighterAddress(); + if (injectedAddress) { + return injectedAddress; + } + const access = await requestFreighterAccess(); if (access.error || !access.address) { throw new Error(access.error || 'Freighter did not return an address'); @@ -80,6 +163,15 @@ const freighterAdapter: WalletProvider = { }, disconnect: async () => {}, getPublicKey: async () => { + const injected = resolveInjectedFreighter(); + if (injected?.getAddress) { + const injectedAddress = await injected.getAddress(); + return injectedAddress.error || !injectedAddress.address ? null : injectedAddress.address; + } + if (injected?.getPublicKey) { + return injected.getPublicKey(); + } + const address = await getFreighterAddress(); if (address.error || !address.address) { return null; @@ -88,6 +180,11 @@ const freighterAdapter: WalletProvider = { return address.address; }, signTransaction: async (xdr: string) => { + const injectedResult = await signWithInjectedFreighter(xdr); + if (injectedResult) { + return injectedResult; + } + const result = await signFreighterTransaction(xdr); if (result.error || !result.signedTxXdr) { throw new Error(result.error || 'Freighter could not sign the transaction'); @@ -160,6 +257,8 @@ interface WalletContextType { isConnected: boolean; connected: boolean; error: string | null; + transactionState: Web3TransactionStatus; + transactionContext: Web3TransactionContext; connect: (providerName: string) => Promise; disconnect: () => Promise; signTransaction: (xdr: string) => Promise; @@ -173,6 +272,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const [activeWallet, setActiveWallet] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); + const [transactionSnapshot, sendTransaction] = useMachine(web3TransactionMachine); // Restore session useEffect(() => { @@ -181,27 +281,31 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const { wallet, pk } = JSON.parse(saved); setActiveWallet(wallet); setPublicKey(pk); + sendTransaction({ type: 'WALLET_CONNECTED', walletName: wallet, publicKey: pk }); } - }, []); + }, [sendTransaction]); const connect = useCallback(async (providerName: string) => { const provider = WALLET_PROVIDERS.find((p) => p.name === providerName); if (!provider) throw new Error(`Unknown wallet: ${providerName}`); setIsConnecting(true); setError(null); + sendTransaction({ type: 'CONNECT_WALLET', walletName: providerName }); try { const pk = await provider.connect(); setPublicKey(pk); setActiveWallet(providerName); localStorage.setItem('stellar_wallet', JSON.stringify({ wallet: providerName, pk })); + sendTransaction({ type: 'WALLET_CONNECTED', walletName: providerName, publicKey: pk }); } catch (e) { const msg = e instanceof Error ? e.message : 'Connection failed'; setError(msg); + sendTransaction({ type: 'FAIL', error: msg }); throw e; } finally { setIsConnecting(false); } - }, []); + }, [sendTransaction]); const disconnect = useCallback(async () => { const provider = WALLET_PROVIDERS.find((p) => p.name === activeWallet); @@ -209,15 +313,25 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { setPublicKey(null); setActiveWallet(null); localStorage.removeItem('stellar_wallet'); - }, [activeWallet]); + sendTransaction({ type: 'DISCONNECT_WALLET' }); + }, [activeWallet, sendTransaction]); const signTransaction = useCallback( async (xdr: string) => { const provider = WALLET_PROVIDERS.find((p) => p.name === activeWallet); if (!provider) throw new Error('No wallet connected'); - return provider.signTransaction(xdr); + sendTransaction({ type: 'REQUEST_SIGNATURE', transactionXdr: xdr }); + try { + const signedXdr = await provider.signTransaction(xdr); + sendTransaction({ type: 'SIGNATURE_APPROVED', signedTransactionXdr: signedXdr }); + return signedXdr; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Transaction signing failed'; + sendTransaction({ type: 'FAIL', error: msg }); + throw e; + } }, - [activeWallet] + [activeWallet, sendTransaction] ); return ( @@ -229,6 +343,8 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { isConnected: !!publicKey, connected: !!publicKey, error, + transactionState: transactionSnapshot.value as Web3TransactionStatus, + transactionContext: transactionSnapshot.context, connect, disconnect, signTransaction, diff --git a/frontend/src/contexts/Web3OnboardingContext.tsx b/frontend/src/contexts/Web3OnboardingContext.tsx index 55bc333f..3e8aece4 100644 --- a/frontend/src/contexts/Web3OnboardingContext.tsx +++ b/frontend/src/contexts/Web3OnboardingContext.tsx @@ -1,5 +1,7 @@ +'use client'; + import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; -import Joyride, { CallBackProps, STATUS, Step, ACTIONS, EVENTS } from 'react-joyride'; +import { Joyride, CallBackProps, STATUS, Step, ACTIONS, EVENTS } from 'react-joyride'; import { useWallet } from './WalletContext'; import { api } from '@/lib/api'; diff --git a/frontend/src/lib/web3/transactionMachine.ts b/frontend/src/lib/web3/transactionMachine.ts new file mode 100644 index 00000000..790f45ea --- /dev/null +++ b/frontend/src/lib/web3/transactionMachine.ts @@ -0,0 +1,283 @@ +import { assign, createMachine } from 'xstate'; + +export type Web3TransactionStatus = + | 'idle' + | 'detectingWallet' + | 'connectingWallet' + | 'connected' + | 'switchingNetwork' + | 'awaitingSignature' + | 'submittingTransaction' + | 'confirmed' + | 'failed' + | 'disconnected'; + +export interface Web3TransactionContext { + walletName: string | null; + publicKey: string | null; + network: string; + transactionXdr: string | null; + signedTransactionXdr: string | null; + transactionHash: string | null; + error: string | null; +} + +export type Web3TransactionEvent = + | { type: 'DETECT_WALLET'; walletName: string } + | { type: 'CONNECT_WALLET'; walletName: string } + | { type: 'WALLET_CONNECTED'; walletName: string; publicKey: string } + | { type: 'SWITCH_NETWORK'; network: string } + | { type: 'NETWORK_SWITCHED'; network: string } + | { type: 'REQUEST_SIGNATURE'; transactionXdr: string } + | { type: 'SIGNATURE_APPROVED'; signedTransactionXdr: string } + | { type: 'SUBMIT_TRANSACTION' } + | { type: 'TRANSACTION_CONFIRMED'; transactionHash: string } + | { type: 'DISCONNECT_WALLET' } + | { type: 'RESET' } + | { type: 'FAIL'; error: string }; + +const initialContext: Web3TransactionContext = { + walletName: null, + publicKey: null, + network: 'TESTNET', + transactionXdr: null, + signedTransactionXdr: null, + transactionHash: null, + error: null, +}; + +const resetWalletContext = (): Partial => ({ + walletName: null, + publicKey: null, + transactionXdr: null, + signedTransactionXdr: null, + transactionHash: null, + error: null, +}); + +export const web3TransactionMachine = createMachine({ + id: 'web3Transaction', + types: {} as { + context: Web3TransactionContext; + events: Web3TransactionEvent; + }, + initial: 'idle', + context: initialContext, + states: { + idle: { + on: { + DETECT_WALLET: { + target: 'detectingWallet', + actions: assign(({ event }) => ({ + walletName: event.walletName, + error: null, + })), + }, + CONNECT_WALLET: { + target: 'connectingWallet', + actions: assign(({ event }) => ({ + walletName: event.walletName, + error: null, + })), + }, + }, + }, + detectingWallet: { + on: { + CONNECT_WALLET: { + target: 'connectingWallet', + actions: assign(({ event }) => ({ + walletName: event.walletName, + error: null, + })), + }, + FAIL: { + target: 'failed', + actions: assign(({ event }) => ({ + error: event.error, + })), + }, + }, + }, + connectingWallet: { + on: { + WALLET_CONNECTED: { + target: 'connected', + actions: assign(({ event }) => ({ + walletName: event.walletName, + publicKey: event.publicKey, + error: null, + })), + }, + FAIL: { + target: 'failed', + actions: assign(({ event }) => ({ + error: event.error, + })), + }, + }, + }, + connected: { + on: { + SWITCH_NETWORK: { + target: 'switchingNetwork', + actions: assign(({ event }) => ({ + network: event.network, + error: null, + })), + }, + REQUEST_SIGNATURE: { + target: 'awaitingSignature', + actions: assign(({ event }) => ({ + transactionXdr: event.transactionXdr, + signedTransactionXdr: null, + transactionHash: null, + error: null, + })), + }, + DISCONNECT_WALLET: { + target: 'disconnected', + actions: assign(resetWalletContext), + }, + }, + }, + switchingNetwork: { + on: { + NETWORK_SWITCHED: { + target: 'connected', + actions: assign(({ event }) => ({ + network: event.network, + error: null, + })), + }, + FAIL: { + target: 'failed', + actions: assign(({ event }) => ({ + error: event.error, + })), + }, + }, + }, + awaitingSignature: { + on: { + SIGNATURE_APPROVED: { + target: 'submittingTransaction', + actions: assign(({ event }) => ({ + signedTransactionXdr: event.signedTransactionXdr, + error: null, + })), + }, + FAIL: { + target: 'failed', + actions: assign(({ event }) => ({ + error: event.error, + })), + }, + }, + }, + submittingTransaction: { + on: { + TRANSACTION_CONFIRMED: { + target: 'confirmed', + actions: assign(({ event }) => ({ + transactionHash: event.transactionHash, + error: null, + })), + }, + FAIL: { + target: 'failed', + actions: assign(({ event }) => ({ + error: event.error, + })), + }, + }, + }, + confirmed: { + on: { + REQUEST_SIGNATURE: { + target: 'awaitingSignature', + actions: assign(({ event }) => ({ + transactionXdr: event.transactionXdr, + signedTransactionXdr: null, + transactionHash: null, + error: null, + })), + }, + DISCONNECT_WALLET: { + target: 'disconnected', + actions: assign(resetWalletContext), + }, + RESET: { + target: 'connected', + actions: assign({ + transactionXdr: null, + signedTransactionXdr: null, + transactionHash: null, + error: null, + }), + }, + }, + }, + failed: { + on: { + CONNECT_WALLET: { + target: 'connectingWallet', + actions: assign(({ event }) => ({ + walletName: event.walletName, + error: null, + })), + }, + REQUEST_SIGNATURE: { + target: 'awaitingSignature', + actions: assign(({ event }) => ({ + transactionXdr: event.transactionXdr, + error: null, + })), + }, + DISCONNECT_WALLET: { + target: 'disconnected', + actions: assign(resetWalletContext), + }, + RESET: { + target: 'idle', + actions: assign(initialContext), + }, + }, + }, + disconnected: { + on: { + CONNECT_WALLET: { + target: 'connectingWallet', + actions: assign(({ event }) => ({ + walletName: event.walletName, + error: null, + })), + }, + RESET: { + target: 'idle', + actions: assign(initialContext), + }, + }, + }, + }, +}); + +export const web3TransactionStateOrder: Web3TransactionStatus[] = [ + 'idle', + 'detectingWallet', + 'connectingWallet', + 'connected', + 'switchingNetwork', + 'awaitingSignature', + 'submittingTransaction', + 'confirmed', + 'failed', + 'disconnected', +]; + +export function getWeb3TransactionStatus(value: unknown): Web3TransactionStatus { + return typeof value === 'string' && + web3TransactionStateOrder.includes(value as Web3TransactionStatus) + ? (value as Web3TransactionStatus) + : 'idle'; +}