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:
parent
c9e84b2c34
commit
16abdcbefb
2 changed files with 64 additions and 2 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue