fix: address review findings in stake-rpc.ts (#518)

- Remove dead krkAddress field from UnstakeRpcConfig (bug)
- Drop swap.js import to avoid transitive Playwright dependency; fix
  header comment to accurately describe the module boundary (warning)
- Inline pollReceipt() returning TxReceipt so snatch receipt is reused
  for log parsing without a second round-trip (warning)
- Use ZeroAddress from ethers instead of manual constant (info)
- Add comment on fromBlock '0x0' genesis-scan caveat (info)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-09 02:48:51 +00:00
parent 1af022624f
commit 722ecaaa0e

View file

@ -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<TxReceipt> {
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<bigint> {
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<bigint> {
[],
]);
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<void> {
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<void> {
* 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,