diff --git a/scripts/harb-evaluator/helpers/stake-rpc.ts b/scripts/harb-evaluator/helpers/stake-rpc.ts index ebcaa66..3773559 100644 --- a/scripts/harb-evaluator/helpers/stake-rpc.ts +++ b/scripts/harb-evaluator/helpers/stake-rpc.ts @@ -1,19 +1,23 @@ /** * RPC-only staking helpers for the red-team agent. * - * All functions use ethers directly — no Playwright/browser dependency. - * Uses the same ethers + rpcCall pattern as market.ts and recenter.ts. + * No browser UI interaction required. Uses ethers + rpcCall directly + * (same pattern as market.ts and recenter.ts). * - * stakeViaRpc — approve KRK to Stake, call snatch() with empty positionsToSnatch - * unstakeViaRpc — call exitPosition() + * Note: importing from swap.js would drag in Playwright via its top-level + * `import { expect } from '@playwright/test'`. This file avoids that import + * by inlining a receipt poller that returns the receipt object. + * + * stakeViaRpc — approve KRK to Stake, call snatch() with empty positionsToSnatch + * unstakeViaRpc — call exitPosition() * getStakingPositions — scan PositionCreated events and filter active positions - * getStakingState — read averageTaxRate and percentageStaked from the contract + * getStakingState — read averageTaxRate and percentageStaked from the contract */ -import { Interface, JsonRpcProvider, Wallet } from 'ethers'; +import { Interface, JsonRpcProvider, Wallet, ZeroAddress } from 'ethers'; import { rpcCall } from './rpc.js'; -import { waitForReceipt } from './swap.js'; const STAKE_ABI = [ + // taxRate param is the index into the TAX_RATES array (0-4), not a raw rate value 'function snatch(uint256 assets, address receiver, uint32 taxRate, uint256[] positionsToSnatch) returns (uint256 positionId)', 'function exitPosition(uint256 positionId)', 'function positions(uint256 positionId) view returns (uint256 share, address owner, uint32 creationTime, uint32 lastTaxTime, uint32 taxRate)', @@ -37,7 +41,6 @@ export interface UnstakeRpcConfig { rpcUrl: string; privateKey: string; stakeAddress: string; - krkAddress: string; positionId: bigint; } @@ -61,7 +64,27 @@ export interface StakingState { const stakeIface = new Interface(STAKE_ABI); const erc20Iface = new Interface(ERC20_ABI); -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +type ReceiptLog = { address: string; topics: string[]; data: string }; +type TxReceipt = { status: string; logs: ReceiptLog[] }; + +/** + * Poll eth_getTransactionReceipt until the transaction is mined. + * Returns the receipt so callers can parse logs without a second round-trip. + * Throws if the transaction reverts (status 0x0) or times out. + */ +async function pollReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): Promise { + for (let i = 0; i < maxAttempts; i++) { + const receipt = (await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [txHash])) as TxReceipt | null; + if (receipt !== null) { + if (receipt.status === '0x0') throw new Error(`Transaction ${txHash} reverted (status 0x0)`); + return receipt; + } + // eslint-disable-next-line no-restricted-syntax -- Polling with timeout: no push source for tx receipt over HTTP RPC. See AGENTS.md #Engineering Principles. + await new Promise(r => setTimeout(r, 500)); + } + throw new Error(`Transaction ${txHash} not mined after ${maxAttempts * 500}ms`); +} // ── Exported helpers ────────────────────────────────────────────────────────── @@ -80,7 +103,7 @@ export async function stakeViaRpc(config: StakeRpcConfig): Promise { console.log(`[stake-rpc] Approving ${config.amount} KRK to Stake contract...`); const approveData = erc20Iface.encodeFunctionData('approve', [config.stakeAddress, config.amount]); const approveTx = await wallet.sendTransaction({ to: config.krkAddress, data: approveData }); - await waitForReceipt(config.rpcUrl, approveTx.hash); + await pollReceipt(config.rpcUrl, approveTx.hash); console.log('[stake-rpc] Approve mined'); // Step 2: call snatch() — empty positionsToSnatch = simple stake with no snatching @@ -94,15 +117,13 @@ export async function stakeViaRpc(config: StakeRpcConfig): Promise { [], ]); const snatchTx = await wallet.sendTransaction({ to: config.stakeAddress, data: snatchData }); - await waitForReceipt(config.rpcUrl, snatchTx.hash); + // pollReceipt returns the receipt directly — no second round-trip needed for log parsing + const receipt = await pollReceipt(config.rpcUrl, snatchTx.hash); console.log(`[stake-rpc] Stake mined: ${snatchTx.hash}`); provider.destroy(); // Parse positionId from the PositionCreated event in the receipt - const receipt = (await rpcCall(config.rpcUrl, 'eth_getTransactionReceipt', [snatchTx.hash])) as { - logs: Array<{ address: string; topics: string[]; data: string }>; - }; const positionCreatedTopic = stakeIface.getEvent('PositionCreated')!.topicHash; const log = receipt.logs.find( l => @@ -129,7 +150,7 @@ export async function unstakeViaRpc(config: UnstakeRpcConfig): Promise { console.log(`[stake-rpc] Calling exitPosition(${config.positionId})...`); const data = stakeIface.encodeFunctionData('exitPosition', [config.positionId]); const tx = await wallet.sendTransaction({ to: config.stakeAddress, data }); - await waitForReceipt(config.rpcUrl, tx.hash); + await pollReceipt(config.rpcUrl, tx.hash); console.log(`[stake-rpc] ✅ Unstake mined: ${tx.hash}`); provider.destroy(); @@ -141,6 +162,9 @@ export async function unstakeViaRpc(config: UnstakeRpcConfig): Promise { * Discovers positions by scanning PositionCreated events filtered by owner, * then confirms each one is still active (non-zero share / non-zero owner) * by reading the positions() mapping directly. + * + * Note: fromBlock '0x0' scans from genesis — acceptable for local Anvil; + * would need a deploy-block offset for use against a live node. */ export async function getStakingPositions(config: { rpcUrl: string; @@ -178,7 +202,7 @@ export async function getStakingPositions(config: { const owner = decoded[1] as string; // Exited positions have owner reset to zero address - if (owner.toLowerCase() !== ZERO_ADDRESS && share > 0n) { + if (owner.toLowerCase() !== ZeroAddress && share > 0n) { active.push({ positionId, share,