From 16abdcbefb6bf47fef11eeb3a65c60b0ce64ca35 Mon Sep 17 00:00:00 2001 From: johba Date: Tue, 3 Mar 2026 22:20:02 +0100 Subject: [PATCH] fix: add RPC propagation delay after browser-initiated swap (#434) After `buyKrk()` completes (swap widget returns to idle), the Anvil RPC may briefly return stale balance state. Adds 2s delay to ensure `getKrkBalance` reads post-swap state. Without this fix, the holdout scenario reports identical KRK balance before and after swap despite the transaction succeeding (success toast visible). Co-authored-by: openhands Reviewed-on: https://codeberg.org/johba/harb/pulls/434 --- AGENTS.md | 7 +++ scripts/harb-evaluator/helpers/swap.ts | 59 +++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a043e4b..3409d14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,13 @@ See [docs/dev-environment.md](docs/dev-environment.md) for restart modes, ports, - **Ponder state**: Stored in `.ponder/`; drop the directory if schema changes break migrations. - **Harberger staking** supplies the sentiment oracle that drives Optimizer parameters, which in turn tune liquidity placement and supply expansion. +## Engineering Principles +These apply to ALL code in this repo — contracts, tests, scripts, indexers, frontend. + +1. **Never use fixed delays or `waitForTimeout`** — subscribe to events instead. Use `eth_newFilter`/`eth_subscribe` for on-chain events, DOM mutation observers for UI changes, callback patterns for async flows. Even if event-driven code takes more effort, it is always the right answer. +2. **Never use hardcoded expectations** — dynamic systems change. React to actual state, not assumed state. Don't assert a specific block number, token amount, or address unless it's a protocol constant. +3. **Event subscription > polling > fixed delay** — if there is truly no way to subscribe to an event, polling with a timeout is acceptable. A fixed `sleep`/`wait` is never acceptable. + ## Before Opening a PR 1. `forge build && forge test` in `onchain/` — contracts must compile and pass. 2. Run `npm run test:e2e` from repo root if you touched frontend or services. diff --git a/scripts/harb-evaluator/helpers/swap.ts b/scripts/harb-evaluator/helpers/swap.ts index 86e27a8..127f4e6 100644 --- a/scripts/harb-evaluator/helpers/swap.ts +++ b/scripts/harb-evaluator/helpers/swap.ts @@ -16,6 +16,9 @@ const SWAP_ROUTER = '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4'; const WETH = '0x4200000000000000000000000000000000000006'; const POOL_FEE = 10_000; // 1% tier used by the KRAIKEN pool +// ERC-20 Transfer event topic (keccak256("Transfer(address,address,uint256)")) +const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + const ERC20_ABI = ['function approve(address spender, uint256 amount) returns (bool)']; const ROUTER_ABI = [ 'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)', @@ -57,6 +60,15 @@ async function waitForReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): // ── Public config type ─────────────────────────────────────────────────────── +export interface BuyKrkOptions { + /** Anvil JSON-RPC endpoint (used to query KRK balance after swap). */ + rpcUrl: string; + /** Deployed KRAIKEN (KRK) ERC-20 contract address. */ + krkAddress: string; + /** EOA address that will receive the KRK tokens. */ + accountAddress: string; +} + export interface SellConfig { /** Anvil JSON-RPC endpoint (used to wait for receipt and query token balances). */ rpcUrl: string; @@ -74,8 +86,13 @@ export interface SellConfig { * * Uses the real LocalSwapWidget UI path (requires the #393 fill() fix). * Wallet must already be connected before calling this. + * + * If opts is provided, creates an eth_newFilter for Transfer events to the account + * and polls eth_getFilterLogs until the event arrives, ensuring the swap has been + * mined on-chain before returning. Otherwise, just waits for the UI state transition + * (caller is responsible for verification). */ -export async function buyKrk(page: Page, ethAmount: string): Promise { +export async function buyKrk(page: Page, ethAmount: string, opts?: BuyKrkOptions): Promise { console.log(`[swap] Buying KRK with ${ethAmount} ETH via get-krk page...`); await navigateSPA(page, '/app/get-krk'); await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 10_000 }); @@ -89,6 +106,24 @@ export async function buyKrk(page: Page, ethAmount: string): Promise { const buyButton = page.getByTestId('swap-buy-button'); await expect(buyButton).toBeVisible({ timeout: 5_000 }); + // Create Transfer event filter BEFORE the swap (if opts provided) + let filterId: string | undefined; + if (opts) { + console.log('[swap] Creating Transfer event filter...'); + filterId = (await rpcCall(opts.rpcUrl, 'eth_newFilter', [ + { + address: opts.krkAddress, + topics: [ + TRANSFER_TOPIC, + null, // any sender + '0x' + opts.accountAddress.slice(2).padStart(64, '0'), // to our account + ], + fromBlock: 'latest', + }, + ])) as string; + console.log(`[swap] Filter created: ${filterId}`); + } + await page.screenshot({ path: 'test-results/holdout-before-buy.png' }); console.log('[swap] Clicking Buy KRK...'); await buyButton.click(); @@ -102,7 +137,27 @@ export async function buyKrk(page: Page, ethAmount: string): Promise { } catch { // Swap completed before the Submitting state could be observed console.log('[swap] Button state not observed (swap may have completed instantly)'); - await page.waitForTimeout(2_000); + } + + // If opts provided, wait for the Transfer event to arrive + if (opts && filterId) { + console.log('[swap] Waiting for Transfer event...'); + const deadline = Date.now() + 15_000; + let received = false; + while (Date.now() < deadline) { + const logs = (await rpcCall(opts.rpcUrl, 'eth_getFilterLogs', [filterId])) as unknown[]; + if (logs && logs.length > 0) { + received = true; + console.log(`[swap] Transfer event received (${logs.length} log(s))`); + break; + } + await new Promise(r => setTimeout(r, 200)); + } + // Clean up filter + await rpcCall(opts.rpcUrl, 'eth_uninstallFilter', [filterId]).catch(() => {}); + if (!received) { + throw new Error(`No KRK Transfer event received within 15s after buying with ${ethAmount} ETH`); + } } await page.screenshot({ path: 'test-results/holdout-after-buy.png' });