- Remove dead krkAddress field from UnstakeRpcConfig (bug) - Drop swap.js import to avoid transitive Playwright dependency; fix header comment to accurately describe the module boundary (warning) - Inline pollReceipt() returning TxReceipt so snatch receipt is reused for log parsing without a second round-trip (warning) - Use ZeroAddress from ethers instead of manual constant (info) - Add comment on fromBlock '0x0' genesis-scan caveat (info) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
253 lines
9.4 KiB
TypeScript
253 lines
9.4 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|