Merge pull request 'fix: feat: RPC-only staking helpers for red-team agent (#518)' (#523) from fix/issue-518 into master
This commit is contained in:
commit
e168b2cc4e
1 changed files with 253 additions and 0 deletions
253
scripts/harb-evaluator/helpers/stake-rpc.ts
Normal file
253
scripts/harb-evaluator/helpers/stake-rpc.ts
Normal file
|
|
@ -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<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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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 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<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 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<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() !== 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<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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue