/** * RPC-only staking helpers for the red-team agent. * * No browser UI interaction required. Uses ethers + rpcCall directly * (same pattern as market.ts and recenter.ts). * * 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 */ import { Interface, JsonRpcProvider, Wallet, ZeroAddress } from 'ethers'; import { rpcCall } from './rpc.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)', 'function getAverageTaxRate() view returns (uint256)', 'function getPercentageStaked() view returns (uint256)', 'event PositionCreated(uint256 indexed positionId, address indexed owner, uint256 kraikenDeposit, uint256 share, uint32 taxRate)', ]; const ERC20_ABI = ['function approve(address spender, uint256 amount) returns (bool)']; export interface StakeRpcConfig { rpcUrl: string; privateKey: string; stakeAddress: string; krkAddress: string; amount: bigint; taxRateIndex: number; } export interface UnstakeRpcConfig { rpcUrl: string; privateKey: string; stakeAddress: string; positionId: bigint; } export interface StakingPosition { positionId: bigint; share: bigint; owner: string; creationTime: number; lastTaxTime: number; taxRate: number; } export interface StakingState { /** Weighted-average tax rate; 1e18 = maximum rate. */ averageTaxRate: bigint; /** Fraction of authorised stake currently staked; 1e18 = 100%. */ percentageStaked: bigint; } // ── Internal helpers ────────────────────────────────────────────────────────── const stakeIface = new Interface(STAKE_ABI); const erc20Iface = new Interface(ERC20_ABI); 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 ────────────────────────────────────────────────────────── /** * Approve KRK to the Stake contract then call snatch() with an empty * positionsToSnatch array, which is the simple-stake (non-snatching) path. * * @returns The new staking position ID. */ export async function stakeViaRpc(config: StakeRpcConfig): Promise { const provider = new JsonRpcProvider(config.rpcUrl); const wallet = new Wallet(config.privateKey, provider); const account = wallet.address; // Step 1: approve KRK spend allowance to the Stake contract 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 pollReceipt(config.rpcUrl, approveTx.hash); console.log('[stake-rpc] Approve mined'); // Step 2: call snatch() — empty positionsToSnatch = simple stake with no snatching console.log( `[stake-rpc] Calling snatch(${config.amount}, ${account}, taxRateIndex=${config.taxRateIndex}, [])...`, ); const snatchData = stakeIface.encodeFunctionData('snatch', [ config.amount, account, config.taxRateIndex, [], ]); const snatchTx = await wallet.sendTransaction({ to: config.stakeAddress, data: snatchData }); // 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 positionCreatedTopic = stakeIface.getEvent('PositionCreated')!.topicHash; const log = receipt.logs.find( l => l.address.toLowerCase() === config.stakeAddress.toLowerCase() && l.topics[0] === positionCreatedTopic, ); if (!log) { throw new Error('[stake-rpc] PositionCreated event not found in receipt'); } const positionId = BigInt(log.topics[1]); console.log(`[stake-rpc] ✅ Stake complete — positionId: ${positionId}`); return positionId; } /** * Call exitPosition() to unstake a position and return KRK to the owner. * Pays the Harberger tax floor before returning assets. */ export async function unstakeViaRpc(config: UnstakeRpcConfig): Promise { const provider = new JsonRpcProvider(config.rpcUrl); const wallet = new Wallet(config.privateKey, provider); 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 pollReceipt(config.rpcUrl, tx.hash); console.log(`[stake-rpc] ✅ Unstake mined: ${tx.hash}`); provider.destroy(); } /** * Return all active staking positions for `account`. * * 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; stakeAddress: string; account: string; }): Promise { const positionCreatedTopic = stakeIface.getEvent('PositionCreated')!.topicHash; const ownerPadded = '0x' + config.account.slice(2).padStart(64, '0'); const logs = (await rpcCall(config.rpcUrl, 'eth_getLogs', [ { address: config.stakeAddress, topics: [positionCreatedTopic, null, ownerPadded], fromBlock: '0x0', toBlock: 'latest', }, ])) as Array<{ topics: string[]; data: string }>; const active: StakingPosition[] = []; for (const log of logs) { const positionId = BigInt(log.topics[1]); // Read the live position state from the mapping const raw = (await rpcCall(config.rpcUrl, 'eth_call', [ { to: config.stakeAddress, data: stakeIface.encodeFunctionData('positions', [positionId]), }, 'latest', ])) as string; const decoded = stakeIface.decodeFunctionResult('positions', raw); const share = BigInt(decoded[0]); const owner = decoded[1] as string; // Exited positions have owner reset to zero address if (owner.toLowerCase() !== ZeroAddress && share > 0n) { active.push({ positionId, share, owner, creationTime: Number(decoded[2]), lastTaxTime: Number(decoded[3]), taxRate: Number(decoded[4]), }); } } console.log(`[stake-rpc] ${active.length} active position(s) for ${config.account}`); return active; } /** * Read the current global staking state from the Stake contract. * * @returns averageTaxRate — weighted-average Harberger tax rate (1e18 = max) * @returns percentageStaked — fraction of authorised supply currently staked (1e18 = 100%) */ export async function getStakingState(config: { rpcUrl: string; stakeAddress: string; }): Promise { const [avgRateRaw, pctStakedRaw] = (await Promise.all([ rpcCall(config.rpcUrl, 'eth_call', [ { to: config.stakeAddress, data: stakeIface.encodeFunctionData('getAverageTaxRate', []), }, 'latest', ]), rpcCall(config.rpcUrl, 'eth_call', [ { to: config.stakeAddress, data: stakeIface.encodeFunctionData('getPercentageStaked', []), }, 'latest', ]), ])) as [string, string]; const averageTaxRate = BigInt(avgRateRaw); const percentageStaked = BigInt(pctStakedRaw); console.log(`[stake-rpc] averageTaxRate=${averageTaxRate} percentageStaked=${percentageStaked}`); return { averageTaxRate, percentageStaked }; }