From fd44fa0bcfaef65458031683917c4bf8aaacd09c Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 9 Mar 2026 02:06:41 +0000 Subject: [PATCH] fix: feat: RPC-only staking helpers for red-team agent (#518) Co-Authored-By: Claude Sonnet 4.6 --- scripts/harb-evaluator/helpers/stake-rpc.ts | 229 ++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 scripts/harb-evaluator/helpers/stake-rpc.ts diff --git a/scripts/harb-evaluator/helpers/stake-rpc.ts b/scripts/harb-evaluator/helpers/stake-rpc.ts new file mode 100644 index 0000000..ebcaa66 --- /dev/null +++ b/scripts/harb-evaluator/helpers/stake-rpc.ts @@ -0,0 +1,229 @@ +/** + * 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. + * + * 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 } from 'ethers'; +import { rpcCall } from './rpc.js'; +import { waitForReceipt } from './swap.js'; + +const STAKE_ABI = [ + '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; + krkAddress: 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); +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +// ── 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 waitForReceipt(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 }); + await waitForReceipt(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 => + 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 waitForReceipt(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. + */ +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() !== ZERO_ADDRESS && 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 }; +}