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 <openhands@all-hands.dev>
Reviewed-on: https://codeberg.org/johba/harb/pulls/434
This commit is contained in:
johba 2026-03-03 22:20:02 +01:00
parent c9e84b2c34
commit 16abdcbefb
2 changed files with 64 additions and 2 deletions

View file

@ -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. - **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. - **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 ## Before Opening a PR
1. `forge build && forge test` in `onchain/` — contracts must compile and pass. 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. 2. Run `npm run test:e2e` from repo root if you touched frontend or services.

View file

@ -16,6 +16,9 @@ const SWAP_ROUTER = '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4';
const WETH = '0x4200000000000000000000000000000000000006'; const WETH = '0x4200000000000000000000000000000000000006';
const POOL_FEE = 10_000; // 1% tier used by the KRAIKEN pool 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 ERC20_ABI = ['function approve(address spender, uint256 amount) returns (bool)'];
const ROUTER_ABI = [ const ROUTER_ABI = [
'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)', '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 ─────────────────────────────────────────────────────── // ── 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 { export interface SellConfig {
/** Anvil JSON-RPC endpoint (used to wait for receipt and query token balances). */ /** Anvil JSON-RPC endpoint (used to wait for receipt and query token balances). */
rpcUrl: string; rpcUrl: string;
@ -74,8 +86,13 @@ export interface SellConfig {
* *
* Uses the real LocalSwapWidget UI path (requires the #393 fill() fix). * Uses the real LocalSwapWidget UI path (requires the #393 fill() fix).
* Wallet must already be connected before calling this. * 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<void> { export async function buyKrk(page: Page, ethAmount: string, opts?: BuyKrkOptions): Promise<void> {
console.log(`[swap] Buying KRK with ${ethAmount} ETH via get-krk page...`); console.log(`[swap] Buying KRK with ${ethAmount} ETH via get-krk page...`);
await navigateSPA(page, '/app/get-krk'); await navigateSPA(page, '/app/get-krk');
await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 10_000 }); 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<void> {
const buyButton = page.getByTestId('swap-buy-button'); const buyButton = page.getByTestId('swap-buy-button');
await expect(buyButton).toBeVisible({ timeout: 5_000 }); 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' }); await page.screenshot({ path: 'test-results/holdout-before-buy.png' });
console.log('[swap] Clicking Buy KRK...'); console.log('[swap] Clicking Buy KRK...');
await buyButton.click(); await buyButton.click();
@ -102,7 +137,27 @@ export async function buyKrk(page: Page, ethAmount: string): Promise<void> {
} catch { } catch {
// Swap completed before the Submitting state could be observed // Swap completed before the Submitting state could be observed
console.log('[swap] Button state not observed (swap may have completed instantly)'); 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' }); await page.screenshot({ path: 'test-results/holdout-after-buy.png' });