harb/scripts/harb-evaluator/helpers/stake-rpc.ts
openhands fd44fa0bcf fix: feat: RPC-only staking helpers for red-team agent (#518)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 02:06:41 +00:00

229 lines
8.2 KiB
TypeScript

/**
* 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<bigint> {
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<void> {
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<StakingPosition[]> {
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<StakingState> {
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 };
}