diff --git a/scripts/harb-evaluator/helpers/stake-rpc.ts b/scripts/harb-evaluator/helpers/stake-rpc.ts new file mode 100644 index 0000000..3773559 --- /dev/null +++ b/scripts/harb-evaluator/helpers/stake-rpc.ts @@ -0,0 +1,253 @@ +/** + * 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 }; +}