2026-03-09 02:06:41 +00:00
/ * *
* RPC - only staking helpers for the red - team agent .
*
2026-03-09 02:48:51 +00:00
* No browser UI interaction required . Uses ethers + rpcCall directly
* ( same pattern as market . ts and recenter . ts ) .
2026-03-09 02:06:41 +00:00
*
2026-03-09 02:48:51 +00:00
* 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 ( )
2026-03-09 02:06:41 +00:00
* getStakingPositions — scan PositionCreated events and filter active positions
2026-03-09 02:48:51 +00:00
* getStakingState — read averageTaxRate and percentageStaked from the contract
2026-03-09 02:06:41 +00:00
* /
2026-03-09 02:48:51 +00:00
import { Interface , JsonRpcProvider , Wallet , ZeroAddress } from 'ethers' ;
2026-03-09 02:06:41 +00:00
import { rpcCall } from './rpc.js' ;
const STAKE_ABI = [
2026-03-09 02:48:51 +00:00
// taxRate param is the index into the TAX_RATES array (0-4), not a raw rate value
2026-03-09 02:06:41 +00:00
'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 ) ;
2026-03-09 02:48:51 +00:00
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 ` ) ;
}
2026-03-09 02:06:41 +00:00
// ── 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 } ) ;
2026-03-09 02:48:51 +00:00
await pollReceipt ( config . rpcUrl , approveTx . hash ) ;
2026-03-09 02:06:41 +00:00
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 } ) ;
2026-03-09 02:48:51 +00:00
// pollReceipt returns the receipt directly — no second round-trip needed for log parsing
const receipt = await pollReceipt ( config . rpcUrl , snatchTx . hash ) ;
2026-03-09 02:06:41 +00:00
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 } ) ;
2026-03-09 02:48:51 +00:00
await pollReceipt ( config . rpcUrl , tx . hash ) ;
2026-03-09 02:06:41 +00:00
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 .
2026-03-09 02:48:51 +00:00
*
* Note : fromBlock '0x0' scans from genesis — acceptable for local Anvil ;
* would need a deploy - block offset for use against a live node .
2026-03-09 02:06:41 +00:00
* /
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
2026-03-09 02:48:51 +00:00
if ( owner . toLowerCase ( ) !== ZeroAddress && share > 0 n ) {
2026-03-09 02:06:41 +00:00
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 ( 1 e18 = max )
* @returns percentageStaked — fraction of authorised supply currently staked ( 1 e18 = 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 } ;
}