Repository Public
1// "Financer l'agent" against the REAL local stack (real API, real signer, virtual passkey), broadcasting OFF.2//3// npx tsx tests/e2e/qa-real-server.mts --port 8690 --db spw_qa_polish --fork base,arbitrum (in another terminal; a FRESH4// stack for every run: the signer's daily counters and the one-shot owner setup are part of what is asserted)5// QA_REAL=1 npx playwright test -c tests/e2e/playwright.config.ts qa-agent-fund6//7// The stack points Base and Arbitrum at local anvil forks where the throwaway wallet holds 0.05 ETH (and 250 USDC on Base).8// Because broadcasting is off, a signed transfer never reaches any chain: the flow must say so and stop in a clear state.9// To walk the second step as well, `settle()` plays the network's part ON THE LOCAL FORK ONLY: it moves the amount with10// anvil's impersonation and marks the proposal finalized in the scratch database. Nothing here can touch a real network.11import { execSync } from 'node:child_process';12import { expect, test, type Page } from '@playwright/test';13import { encodeAbiParameters, keccak256 } from 'viem';14import { enter, passkeyFile, probe, virtualPasskey, watch } from './qa-helpers.ts';1516const BASE = process.env.QA_BASE ?? 'http://localhost:8690', DB = process.env.QA_DB ?? 'spw_qa_polish', PORT = Number(new URL(BASE).port);17const FORK: Record<string, string> = { base: `http://127.0.0.1:${PORT + 1}`, arbitrum: `http://127.0.0.1:${PORT + 2}` };18const SHOTS = process.env.QA_SHOTS ?? 'test-results/qa-fund';19const USDC_ARB = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';20test.skip(!process.env.QA_REAL, 'needs the real local stack: set QA_REAL=1 (see the header)');21test.use({ baseURL: BASE });22test.describe.configure({ mode: 'serial' });2324let rid = 0;25const rpc = async <T>(net: string, method: string, params: unknown[] = []): Promise<T> => { const r = await fetch(FORK[net]!, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++rid, method, params }) }); const j = await r.json() as { result?: T; error?: { message: string } }; if (j.error) throw new Error(`${method}: ${j.error.message}`); return j.result as T; };26const psql = (q: string) => execSync(`psql -At -d ${DB}`, { input: q, encoding: 'utf8' }).trim();27const hex = (v: bigint) => '0x' + v.toString(16);28const pad = (a: string) => a.toLowerCase().replace(/^0x/, '').padStart(64, '0');29/** Test-only stand-in for the network: apply the newest signed-but-not-broadcast transfer on the local fork and finalize it. */30async function settle(): Promise<void> {31 const row = psql(`select id, network, asset, amount, from_addr, to_addr from proposals where state = 'broadcast' order by updated_at desc limit 1`);32 if (!row) throw new Error('no signed transfer to settle');33 const [id, net, asset, amount, from, to] = row.split('|') as [string, string, string, string, string, string];34 if (asset === 'native') { const cur = BigInt(await rpc<string>(net, 'eth_getBalance', [to, 'latest'])); await rpc(net, 'anvil_setBalance', [to, hex(cur + BigInt(amount))]); }35 else { await rpc(net, 'anvil_impersonateAccount', [from]); await rpc(net, 'eth_sendTransaction', [{ from, to: asset, data: `0xa9059cbb${pad(to)}${BigInt(amount).toString(16).padStart(64, '0')}` }]); await rpc(net, 'anvil_stopImpersonatingAccount', [from]); }36 psql(`update proposals set state = 'finalized', error = null, finalized_at = now(), version = version + 1 where id = '${id}'`);37}38const api = (page: Page, method: string, path: string, body?: unknown) => page.evaluate(async ([m, p, b]) => { const r = await fetch('/api/v1' + p, { method: m as string, credentials: 'same-origin', headers: m === 'GET' ? {} : { 'x-spw-csrf': '1', 'content-type': 'application/json' }, body: m === 'GET' ? undefined : JSON.stringify(b ?? {}) }); return { status: r.status, json: await r.json().catch(() => null) as any }; }, [method, path, body] as const);39const createAgent = async (page: Page, name: string, network: string, universe: string[], capital = '60') => { const r = await api(page, 'POST', '/agents', { name, mission: '', network, universe, interval: '1h', capitalUsdc: capital }); expect(r.status, JSON.stringify(r.json)).toBe(200); return r.json as { id: string; vaultAddress: string }; };40async function start(page: Page, context: import('@playwright/test').BrowserContext) {41 const pk = await virtualPasskey(context, page, passkeyFile(PORT));42 const w = watch(page);43 await enter(page, pk, process.env.QA_SETUP_TOKEN ?? 'qa-setup-token');44 return { pk, w };45}46const shot = async (page: Page, name: string) => { await page.waitForTimeout(400); await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false }); };47const noOverflow = async (page: Page) => { const p = await probe(page); expect(p.overflow, p.wide.join(' | ')).toBe(false); };4849test('fund an agent on its own network: fixed network, limits, two transfers one after the other, resume, checklist', async ({ page, context }) => {50 test.setTimeout(240_000);51 const { pk, w } = await start(page, context);52 const ag = await createAgent(page, 'QA Fonds', 'base', ['ETH', 'LINK']);53 await page.goto(`/agents/${ag.id}`);54 const list = page.getByTestId('agent-checklist');55 await expect(list.locator('[data-check=funds]')).toHaveAttribute('data-ok', 'false');56 await expect(list.locator('[data-check=gas]')).toHaveAttribute('data-ok', 'false');57 await expect(list.getByRole('button', { name: 'Financer l’agent' })).toBeVisible(); // the next action is the primary button58 await shot(page, '01-agent-checklist');5960 await list.getByRole('button', { name: 'Financer l’agent' }).click();61 const sheet = page.getByRole('dialog');62 await expect(sheet.getByText('Sur Base uniquement')).toBeVisible();63 await expect(sheet.getByText(ag.vaultAddress.slice(0, 6))).toBeVisible();64 await expect(sheet.getByRole('textbox')).toHaveCount(2); // two amounts; no network, no address to type65 await expect(sheet.getByText(/Votre portefeuille principal détient 250 USDC sur Base/)).toBeVisible({ timeout: 30_000 });66 await expect(sheet.getByText(/Limite du signataire : 50 USDC par transfert, 100 par jour/)).toBeVisible();67 // suggested: the planned capital capped by the per-transfer limit, and what is missing to reach the gas reserve68 await expect(sheet.locator('#qa-fund-cash')).toHaveValue('50');69 const reserve = await sheet.locator('#qa-fund-gas').inputValue(); expect(Number(reserve)).toBeGreaterThan(0);70 await expect(sheet.getByText(/Ce serveur est en mode test/)).toBeVisible();71 await shot(page, '02-fund-sheet'); await noOverflow(page);7273 // refusals in plain words, before anything is prepared74 await sheet.locator('#qa-fund-cash').fill('80');75 await expect(sheet.getByRole('alert')).toContainText('50 USDC au plus par transfert');76 await expect(sheet.getByRole('button', { name: /Envoyer .* à l’agent/ })).toBeDisabled();77 await sheet.locator('#qa-fund-cash').fill('300');78 await expect(sheet.getByRole('alert')).toContainText('Solde de USDC insuffisant sur Base');79 await shot(page, '03-fund-refusal');80 await sheet.getByRole('button', { name: 'Max' }).click();81 await expect(sheet.locator('#qa-fund-cash')).toHaveValue('50');82 await sheet.locator('#qa-fund-cash').fill('40');83 await expect(sheet.getByText(/Après le financement/)).toBeVisible();84 await expect(sheet.getByText(new RegExp(`40 USDC · ${reserve.replace('.', '\\.')} ETH`))).toBeVisible();8586 // step 1/2: USDC, with the passkey; broadcasting is off here, so the flow stops in a clear state87 await sheet.getByRole('button', { name: /Envoyer 40 USDC \+ .* ETH à l’agent/ }).click();88 await expect(sheet.locator('[data-leg=cash]')).toHaveAttribute('data-state', 'test', { timeout: 40_000 });89 await expect(sheet.getByText(/Mode test : la diffusion est désactivée/)).toBeVisible();90 await expect(sheet.locator('[data-leg=gas]')).toHaveAttribute('data-state', 'todo');91 await shot(page, '04-fund-test-mode');9293 // the network's part, on the local fork: the sheet picks the confirmation up and offers step 2/294 await settle();95 await expect(sheet.locator('[data-leg=cash]')).toHaveAttribute('data-state', 'done', { timeout: 20_000 });96 const step2 = sheet.getByRole('button', { name: /Étape 2\/2 : envoyer .* ETH/ });97 await expect(step2).toBeVisible();98 await shot(page, '05-fund-step2');99100 // close and resume: nothing is lost101 await sheet.getByRole('button', { name: 'Fermer et reprendre plus tard' }).click();102 await expect(page.getByRole('dialog')).toHaveCount(0);103 await page.reload();104 await page.getByRole('button', { name: 'Reprendre le financement' }).first().click();105 await expect(sheet.locator('[data-leg=cash]')).toHaveAttribute('data-state', 'done');106 await step2.click();107 await expect(sheet.locator('[data-leg=gas]')).toHaveAttribute('data-state', 'test', { timeout: 40_000 });108 await settle();109 await expect(sheet.getByText(/Financement envoyé sur Base/)).toBeVisible({ timeout: 20_000 });110 await shot(page, '06-fund-done');111 await sheet.getByRole('button', { name: 'Terminer' }).click();112113 // the vault really holds the funds (on the fork), the checklist moved, the next action changed114 await expect(list.locator('[data-check=gas]')).toHaveAttribute('data-ok', 'true', { timeout: 30_000 });115 await expect(list.locator('[data-check=funds]')).toHaveAttribute('data-ok', 'part');116 await expect(list.getByText(/40 \$ US sur 60 prévus, en USDC sur Base/)).toBeVisible();117 await expect(page.getByRole('button', { name: 'Reprendre le financement' })).toHaveCount(0);118 await shot(page, '07-agent-funded');119120 // daily limit: 40 of 100 spent, so 60 left — and at most 50 per transfer121 await page.getByRole('button', { name: 'Financer l’agent' }).first().click();122 await expect(sheet.getByText(/reste 60 aujourd’hui/)).toBeVisible({ timeout: 20_000 });123 await expect(sheet.getByText(/La réserve de frais .* est déjà là/)).toBeVisible();124 await sheet.getByRole('button', { name: 'Annuler' }).click();125126 // withdraw to the main wallet: one asset at a time, the native coin last; signed but not sent on this server, and said so127 await page.getByRole('button', { name: 'Retirer', exact: true }).click();128 await expect(sheet.getByText(/De « QA Fonds » sur Base vers votre portefeuille principal/)).toBeVisible();129 await shot(page, '08-withdraw-sheet');130 await sheet.locator('li').filter({ hasText: 'USDC' }).getByRole('button', { name: 'Retirer' }).click();131 await expect(sheet.getByText(/Retrait de 40 USDC signé, mais non envoyé/)).toBeVisible({ timeout: 40_000 });132 await shot(page, '09-withdraw-test');133 await noOverflow(page);134 await pk.save();135 expect(w.errors.filter((e) => !/status of 4\d\d/.test(e))).toEqual([]);136});137138test('funds on another network: explained, fee top-up offered, then withdrawal; Send names the agent and warns', async ({ page, context }) => {139 test.setTimeout(240_000);140 const { pk, w } = await start(page, context);141 const ag = await createAgent(page, 'QA Ailleurs', 'base', ['ETH']);142 // 20 USDC at the vault's address on ARBITRUM (the owner's mistake), and no ETH there: balance slot of Circle's FiatToken (mapping at slot 9)143 const slot = keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [ag.vaultAddress as `0x${string}`, 9n]));144 await rpc('arbitrum', 'anvil_setStorageAt', [USDC_ARB, slot, '0x' + (20_000000n).toString(16).padStart(64, '0')]);145 await page.goto(`/agents/${ag.id}`);146 const box = page.getByTestId('agent-elsewhere');147 await expect(box.getByText('Fonds sur d’autres réseaux')).toBeVisible({ timeout: 60_000 });148 await expect(box.getByText('20 USDC')).toBeVisible();149 await expect(box.getByText(/L’agent ne peut pas les utiliser/)).toBeVisible();150 await expect(box.getByText(/Le retrait coûte des frais en ETH sur Arbitrum One/)).toBeVisible();151 await expect(box.getByRole('button', { name: 'Retirer' })).toBeDisabled();152 await box.scrollIntoViewIfNeeded(); await shot(page, '10-elsewhere-no-gas');153 await box.getByRole('button', { name: /Envoyer .* ETH pour les frais sur Arbitrum One/ }).click();154 await expect(box.getByText(/Mode test : la diffusion est désactivée/)).toBeVisible({ timeout: 40_000 });155 await settle(); // the fee arrives (local fork)156 await page.reload();157 await expect(box.getByRole('button', { name: 'Retirer' }).first()).toBeEnabled({ timeout: 60_000 });158 await box.getByRole('button', { name: 'Retirer' }).first().click();159 await expect(box.getByText(/Retrait de 20 USDC signé, mais non envoyé/)).toBeVisible({ timeout: 40_000 });160 await box.scrollIntoViewIfNeeded(); await shot(page, '11-elsewhere-withdrawn');161162 // generic Send screen: the recipient is recognised; the wrong network is called out (not blocked)163 await page.goto('/send?n=ethereum');164 await page.locator('textarea').fill(ag.vaultAddress);165 await expect(page.getByRole('alert')).toContainText('portefeuille de votre agent « QA Ailleurs », qui vit sur Base — pas sur Ethereum');166 await shot(page, '12-send-wrong-network');167 await page.getByRole('radio', { name: 'Base', exact: true }).click();168 await page.locator('textarea').fill(ag.vaultAddress);169 await expect(page.getByText(/portefeuille de votre agent « QA Ailleurs », qui vit sur Base\. Pour le financer/)).toBeVisible();170 await expect(page.getByRole('alert')).toHaveCount(0);171 await noOverflow(page);172 await pk.save();173 expect(w.errors.filter((e) => !/status of 4\d\d/.test(e))).toEqual([]);174});175176test('no cash on the agent network: USDC elsewhere is listed and CCTP is offered prefilled; 18-decimal cash leg on BNB Chain', async ({ page, context }) => {177 test.setTimeout(180_000);178 const { pk } = await start(page, context);179 const arb = await createAgent(page, 'QA Arbitrum', 'arbitrum', ['ETH']);180 await page.goto(`/agents/${arb.id}`);181 await page.getByRole('button', { name: 'Financer l’agent' }).first().click();182 const sheet = page.getByRole('dialog');183 await expect(sheet.getByText('Sur Arbitrum One uniquement')).toBeVisible();184 await expect(sheet.getByText(/Pas assez de USDC sur Arbitrum One dans votre portefeuille principal/)).toBeVisible({ timeout: 30_000 });185 await expect(sheet.getByText(/USDC · Base/)).toBeVisible();186 await shot(page, '13-fund-usdc-elsewhere');187 await sheet.getByRole('button', { name: /Transférer des USDC de Base vers Arbitrum One/ }).click();188 await expect(page.getByRole('button', { name: /^Vous payez: USDC Base/ })).toBeVisible({ timeout: 20_000 });189 await expect(page.getByRole('button', { name: /^Vous recevez.*: USDC Arbitrum One/ })).toBeVisible();190191 const bnb = await createAgent(page, 'QA BNB', 'bsc', ['BNB']);192 await page.goto(`/agents/${bnb.id}`);193 await page.getByRole('button', { name: 'Financer l’agent' }).first().click();194 await expect(sheet.getByText('Sur BNB Smart Chain uniquement')).toBeVisible();195 await expect(sheet.getByText(/Binance-Peg USD Coin/)).toBeVisible({ timeout: 30_000 });196 await expect(sheet.getByText(/Il vous faut du BNB sur BNB Smart Chain/)).toBeVisible();197 // 18 decimals: an amount with 18 places is kept exactly, and the limit is the same 50 units198 await sheet.locator('#qa-fund-cash').fill('1.000000000000000001');199 await expect(sheet.getByText(/Limite du signataire : 50 USDC par transfert/)).toBeVisible();200 await expect(sheet.getByRole('alert').first()).toContainText('Solde de USDC insuffisant sur BNB Smart Chain');201 await shot(page, '14-fund-bnb');202 await noOverflow(page);203 await pk.save();204});205