diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index e5f24aa3..5e6b80bf 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,6 +8,7 @@ on: - tim/pro-526-personal-server-agent-operation-poc - chore/PRO-775/integrate-pge - fix/update-lockfile + - staking jobs: publish-prerelease: diff --git a/.releaserc.yaml b/.releaserc.yaml index e4b46ba5..aa8149fc 100644 --- a/.releaserc.yaml +++ b/.releaserc.yaml @@ -1,5 +1,7 @@ branches: - main + - name: staking + prerelease: true preset: "conventionalcommits" plugins: - "@semantic-release/commit-analyzer" diff --git a/examples/vana-sdk-demo/.env.example b/examples/vana-sdk-demo/.env.example new file mode 100644 index 00000000..1f9203bb --- /dev/null +++ b/examples/vana-sdk-demo/.env.example @@ -0,0 +1 @@ +PRIVATE_KEY=0xPrivateKeyHere \ No newline at end of file diff --git a/examples/vana-sdk-demo/staking-example.ts b/examples/vana-sdk-demo/staking-example.ts new file mode 100644 index 00000000..a1ff5d82 --- /dev/null +++ b/examples/vana-sdk-demo/staking-example.ts @@ -0,0 +1,680 @@ +/** + * Example: Using the StakingController to query VanaPool staking information + * + * This example demonstrates how to: + * - Get total VANA staked in the protocol + * - Query active staking entities + * - Get entity details + * - Check staker positions and rewards + * - Stake VANA and verify changes + * + * Run with: npx tsx staking-example.ts + * + * For staking operations, create a .env file with PRIVATE_KEY: + * PRIVATE_KEY=0x... + */ + +import "dotenv/config"; +import { + Vana, + mokshaTestnet, + getAbi, + getContractAddress, +} from "@opendatalabs/vana-sdk/node"; +import { createPublicClient, createWalletClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import type { StakerEntitySummary } from "@opendatalabs/vana-sdk/node"; + +// Subgraph URL for querying indexed staking data +const SUBGRAPH_URL = + "https://api.goldsky.com/api/public/project_cm168cz887zva010j39il7a6p/subgraphs/moksha/staking/gn"; + +async function main() { + const publicClient = createPublicClient({ + chain: mokshaTestnet, + transport: http(), + }); + + // Check if PRIVATE_KEY is available for write operations + const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined; + const hasWallet = !!privateKey; + + let vana: ReturnType; + + if (hasWallet) { + const account = privateKeyToAccount(privateKey); + const walletClient = createWalletClient({ + account, + chain: mokshaTestnet, + transport: http(), + }); + vana = Vana({ + publicClient, + walletClient, + address: account.address, + subgraphUrl: SUBGRAPH_URL, + }); + console.log(`Wallet connected: ${account.address}\n`); + } else { + vana = Vana({ + publicClient, + address: "0x0000000000000000000000000000000000000000", // Placeholder for read-only + subgraphUrl: SUBGRAPH_URL, + }); + console.log("Running in read-only mode (no PRIVATE_KEY set)\n"); + } + + console.log("=== VanaPool Staking Information ===\n"); + + // 1. Get total VANA staked in the protocol + console.log("Fetching total VANA staked..."); + const totalStaked = await vana.staking.getTotalVanaStaked(); + const totalStakedVana = Number(totalStaked) / 1e18; + console.log(`Total VANA staked: ${totalStakedVana.toLocaleString()} VANA\n`); + + // 2. Get active staking entities + console.log("Fetching active entities..."); + const activeEntityIds = await vana.staking.getActiveEntities(); + console.log(`Active entities: ${activeEntityIds.length}`); + console.log(`Entity IDs: ${activeEntityIds.map(String).join(", ")}\n`); + + // 3. Get entity details for each active entity (including distributed rewards from subgraph) + console.log("=== Entity Details ===\n"); + for (const entityId of activeEntityIds) { + const entity = await vana.staking.getEntity(entityId); + const totalDistributedRewards = + await vana.staking.getTotalDistributedRewards(entityId); + + console.log(`Entity #${entityId}:`); + console.log(` Name: ${entity.name}`); + console.log(` Owner: ${entity.ownerAddress}`); + console.log(` Status: ${entity.status}`); + console.log( + ` Active Reward Pool: ${(Number(entity.activeRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Locked Reward Pool: ${(Number(entity.lockedRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Total Distributed Rewards: ${(Number(totalDistributedRewards) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Total Shares: ${(Number(entity.totalShares) / 1e18).toLocaleString()}`, + ); + console.log(` Max APY: ${Number(entity.maxAPY) / 1e18}%`); + console.log(""); + } + + // 4. Get protocol statistics + console.log("=== Protocol Statistics ===\n"); + + const entitiesCount = await vana.staking.getEntitiesCount(); + console.log(`Total entities created: ${entitiesCount}`); + + const activeStakersCount = await vana.staking.getActiveStakersCount(); + console.log(`Active stakers: ${activeStakersCount}`); + + const minStakeAmount = await vana.staking.getMinStakeAmount(); + console.log( + `Minimum stake amount: ${(Number(minStakeAmount) / 1e18).toLocaleString()} VANA`, + ); + + const bondingPeriod = await vana.staking.getBondingPeriod(); + const bondingDays = Number(bondingPeriod) / 86400; + console.log(`Bonding period: ${bondingDays} days`); + + // Helper to format rewards - show in wei if less than 0.000001 VANA + const formatRewards = (wei: bigint): string => { + const vana = Number(wei) / 1e18; + if (wei === 0n) return "0 VANA"; + if (vana < 0.000001) return `${wei.toString()} wei`; + return `${vana.toLocaleString()} VANA`; + }; + + // Helper to format bonding time + const formatBondingTime = (seconds: bigint): string => { + const totalSecs = Number(seconds); + const d = Math.floor(totalSecs / 86400); + const h = Math.floor((totalSecs % 86400) / 3600); + const m = Math.floor((totalSecs % 3600) / 60); + const s = totalSecs % 60; + return `${d}d ${h}h ${m}m ${s}s`; + }; + + // Helper to print staker summary and return it for comparison + const printStakerSummary = async ( + staker: `0x${string}`, + entId: bigint, + label?: string, + ): Promise => { + console.log(`\n=== Staker Summary${label ? ` (${label})` : ""} ===\n`); + console.log(`Staker: ${staker}`); + console.log(`Entity ID: ${entId}\n`); + + const summary = await vana.staking.getStakerSummary(staker, entId); + + console.log(`Shares: ${summary.shares.toString()}`); + console.log( + `Cost Basis: ${(Number(summary.costBasis) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Current Value: ${(Number(summary.currentValue) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Appreciation: ${((Number(summary.currentValue) - Number(summary.costBasis)) / 1e18).toLocaleString()} VANA`, + ); + console.log(`\nBonding Status:`); + console.log(` In Bonding Period: ${summary.isInBondingPeriod}`); + console.log( + ` Remaining Bonding Time: ${formatBondingTime(summary.remainingBondingTime)}`, + ); + console.log( + ` Eligibility Timestamp: ${new Date(Number(summary.rewardEligibilityTimestamp) * 1000).toISOString()}`, + ); + console.log(`\nRewards:`); + console.log(` Vested Rewards: ${formatRewards(summary.vestedRewards)}`); + console.log( + ` Unvested Rewards: ${formatRewards(summary.unvestedRewards)}`, + ); + console.log( + ` Realized Rewards: ${formatRewards(summary.realizedRewards)}`, + ); + console.log(` Total Earned: ${formatRewards(summary.earnedRewards)}`); + + return summary; + }; + + // 5. Get staker summaries + const entityId = 1n; + + await printStakerSummary( + "0x2AC93684679a5bdA03C6160def908CdB8D46792f" as `0x${string}`, + entityId, + ); + + await printStakerSummary( + "0x2c6A694c3C50f012d8287fD9dB4CF98c99680a81" as `0x${string}`, + entityId, + ); + + // 6. Stake test with before/after comparison + if (hasWallet) { + console.log("\n=== Stake Test ===\n"); + + const stakeEntityId = 1n; + const stakeAmount = "0.01"; // 0.01 VANA + const account = privateKeyToAccount(privateKey); + const stakerAddress = account.address; + + // Get entity info before stake + const entityBefore = await vana.staking.getEntity(stakeEntityId); + console.log(`Entity #${stakeEntityId} before stake:`); + console.log( + ` Active Reward Pool: ${(Number(entityBefore.activeRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Locked Reward Pool: ${(Number(entityBefore.lockedRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log(` Total Shares: ${entityBefore.totalShares.toString()}`); + + // Get staker summary before stake + const summaryBefore = await printStakerSummary( + stakerAddress, + stakeEntityId, + "Before Stake", + ); + + // Perform stake + console.log(`\nStaking ${stakeAmount} VANA to entity #${stakeEntityId}...`); + const txHash = await vana.staking.stake({ + entityId: stakeEntityId, + amount: stakeAmount, + }); + console.log(`Transaction hash: ${txHash}`); + + // Wait for transaction confirmation + console.log("Waiting for transaction confirmation..."); + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash, + }); + console.log(`Transaction confirmed in block ${receipt.blockNumber}`); + + // Get share price at stake block to compute exact expected values + const entityAddress = getContractAddress( + mokshaTestnet.id, + "VanaPoolEntity", + ); + const entityAbi = getAbi("VanaPoolEntity"); + const shareToVanaAtStake = (await publicClient.readContract({ + address: entityAddress, + abi: entityAbi, + functionName: "entityShareToVana", + args: [stakeEntityId], + blockNumber: receipt.blockNumber, + })) as bigint; + console.log( + `Share price at stake block: ${(Number(shareToVanaAtStake) / 1e18).toFixed(18)} VANA/share`, + ); + + // Get entity info after stake + const entityAfter = await vana.staking.getEntity(stakeEntityId); + console.log(`\nEntity #${stakeEntityId} after stake:`); + console.log( + ` Active Reward Pool: ${(Number(entityAfter.activeRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Locked Reward Pool: ${(Number(entityAfter.lockedRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log(` Total Shares: ${entityAfter.totalShares.toString()}`); + + // Get staker summary after stake + const summaryAfter = await printStakerSummary( + stakerAddress, + stakeEntityId, + "After Stake", + ); + + // Verify changes + console.log("\n=== Stake Verification ===\n"); + + const stakeAmountWei = BigInt(Math.floor(Number(stakeAmount) * 1e18)); + const sharesDiff = + BigInt(summaryAfter.shares) - BigInt(summaryBefore.shares); + const costBasisDiff = + BigInt(summaryAfter.costBasis) - BigInt(summaryBefore.costBasis); + const currentValueDiff = + summaryAfter.currentValue - summaryBefore.currentValue; + const activePoolDiff = + entityAfter.activeRewardPool - entityBefore.activeRewardPool; + const lockedPoolDiff = + entityBefore.lockedRewardPool - entityAfter.lockedRewardPool; // Decrease in locked + const entitySharesDiff = + BigInt(entityAfter.totalShares) - BigInt(entityBefore.totalShares); + + // Calculate expected current value of new shares using share price at stake block + // The contract uses truncating division: costBasis = (shares * shareToVana) / 1e18 + const expectedCurrentValueOfNewShares = + (sharesDiff * shareToVanaAtStake) / 10n ** 18n; + + console.log( + `Stake Amount: ${stakeAmount} VANA (${stakeAmountWei.toString()} wei)`, + ); + console.log(`Shares Gained: ${sharesDiff.toString()}`); + console.log( + `Cost Basis Increase: ${(Number(costBasisDiff) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Current Value Increase: ${(Number(currentValueDiff) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Expected Current Value (at stake block): ${(Number(expectedCurrentValueOfNewShares) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Active Pool Increase: ${(Number(activePoolDiff) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Locked Pool Decrease: ${(Number(lockedPoolDiff) / 1e18).toLocaleString()} VANA`, + ); + console.log(`Entity Shares Increase: ${entitySharesDiff.toString()}`); + + // Validate + // Cost basis should equal the expected current value at stake time (shares * sharePrice at stake block) + const costBasisMatchesExpectedValue = + costBasisDiff === expectedCurrentValueOfNewShares; + // Active pool increases by exactly: stake amount + rewards moved from locked pool + const expectedActivePoolIncrease = stakeAmountWei + lockedPoolDiff; + const activePoolCorrect = activePoolDiff === expectedActivePoolIncrease; + const sharesCorrect = sharesDiff > 0n; + const sharesMatchEntity = sharesDiff === entitySharesDiff; + + console.log("\nValidation:"); + console.log( + ` ✓ Cost basis equals expected value at stake block: ${costBasisMatchesExpectedValue ? "PASS" : "FAIL"}`, + ); + if (!costBasisMatchesExpectedValue) { + console.log( + ` Cost basis: ${costBasisDiff.toString()}, Expected: ${expectedCurrentValueOfNewShares.toString()}`, + ); + } + console.log( + ` ✓ Active pool increased by stake + locked rewards: ${activePoolCorrect ? "PASS" : "FAIL"} (expected: ${(Number(expectedActivePoolIncrease) / 1e18).toLocaleString()} VANA)`, + ); + console.log(` ✓ Shares received: ${sharesCorrect ? "PASS" : "FAIL"}`); + console.log( + ` ✓ Staker shares match entity shares increase: ${sharesMatchEntity ? "PASS" : "FAIL"}`, + ); + + if ( + costBasisMatchesExpectedValue && + activePoolCorrect && + sharesCorrect && + sharesMatchEntity + ) { + console.log("\n✅ All stake validations passed!"); + } else { + console.log("\n❌ Some validations failed. Check the results above."); + } + + // 7. Test getMaxUnstakeAmount + console.log("\n=== Max Unstake Amount Test ===\n"); + + // Get max unstake amount from contract + const maxUnstakeResult = await vana.staking.getMaxUnstakeAmount( + stakerAddress, + stakeEntityId, + ); + const summaryNow = await vana.staking.getStakerSummary( + stakerAddress, + stakeEntityId, + ); + + console.log("Contract getMaxUnstakeAmount result:"); + console.log( + ` Max VANA: ${(Number(maxUnstakeResult.maxVana) / 1e18).toLocaleString()} VANA`, + ); + console.log(` Max Shares: ${maxUnstakeResult.maxShares.toString()}`); + console.log( + ` Limiting Factor: ${maxUnstakeResult.limitingFactor} (0=user, 1=activePool, 2=treasury)`, + ); + console.log(` In Bonding Period: ${maxUnstakeResult.isInBondingPeriod}`); + + // Calculate expected max unstake based on bonding period status + const calculateExpectedMaxUnstake = ( + summary: StakerEntitySummary, + ): bigint => { + if (summary.isInBondingPeriod) { + // During bonding period: max = cost basis + return summary.costBasis; + } else { + // After bonding period: max = current value (cost basis + all rewards) + return summary.currentValue; + } + }; + + const expectedMaxUnstake = calculateExpectedMaxUnstake(summaryNow); + + console.log("\nExpected calculation:"); + console.log(` Is In Bonding Period: ${summaryNow.isInBondingPeriod}`); + console.log( + ` Cost Basis: ${(Number(summaryNow.costBasis) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Current Value: ${(Number(summaryNow.currentValue) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Expected Max Unstake: ${(Number(expectedMaxUnstake) / 1e18).toLocaleString()} VANA`, + ); + + // Validate (contract may return less if limited by activePool or treasury) + console.log("\nValidation:"); + if (maxUnstakeResult.limitingFactor === 0) { + // User-limited: contract should match expected + const maxUnstakeCorrect = maxUnstakeResult.maxVana === expectedMaxUnstake; + console.log( + ` ✓ Max unstake matches expected (user-limited): ${maxUnstakeCorrect ? "PASS" : "FAIL"}`, + ); + if (!maxUnstakeCorrect) { + console.log( + ` Difference: ${(Number(maxUnstakeResult.maxVana - expectedMaxUnstake) / 1e18).toLocaleString()} VANA`, + ); + } + } else { + // Pool or treasury limited: contract should be <= expected + const maxUnstakeValid = maxUnstakeResult.maxVana <= expectedMaxUnstake; + console.log( + ` ✓ Max unstake <= expected (${maxUnstakeResult.limitingFactor === 1 ? "pool" : "treasury"}-limited): ${maxUnstakeValid ? "PASS" : "FAIL"}`, + ); + } + + // Verify bonding period status matches + const bondingStatusMatch = + maxUnstakeResult.isInBondingPeriod === summaryNow.isInBondingPeriod; + console.log( + ` ✓ Bonding period status matches: ${bondingStatusMatch ? "PASS" : "FAIL"}`, + ); + + // Show what happens at different times + console.log("\n--- Max Unstake at Different Times ---\n"); + + if (summaryNow.isInBondingPeriod) { + console.log("Currently IN bonding period:"); + console.log( + ` Max unstake = cost basis = ${(Number(summaryNow.costBasis) / 1e18).toLocaleString()} VANA`, + ); + console.log(` (Rewards would be forfeited if unstaking now)`); + console.log("\nAfter bonding period ends:"); + console.log( + ` Max unstake = current value = ${(Number(summaryNow.currentValue) / 1e18).toLocaleString()} VANA`, + ); + console.log(` (Full value including rewards can be unstaked)`); + } else { + console.log("Bonding period has ENDED:"); + console.log( + ` Max unstake = current value = ${(Number(summaryNow.currentValue) / 1e18).toLocaleString()} VANA`, + ); + console.log(` This includes:`); + console.log( + ` - Cost basis: ${(Number(summaryNow.costBasis) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` - Appreciation (unvested): ${(Number(summaryNow.unvestedRewards) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` - Vested rewards: ${(Number(summaryNow.vestedRewards) / 1e18).toLocaleString()} VANA`, + ); + } + + // 8. Test unstake with max amount + console.log("\n=== Unstake Test ===\n"); + + // Get fresh max unstake amount before unstaking + const unstakeInfo = await vana.staking.getMaxUnstakeAmount( + stakerAddress, + stakeEntityId, + ); + const summaryBeforeUnstake = await vana.staking.getStakerSummary( + stakerAddress, + stakeEntityId, + ); + const entityBeforeUnstake = await vana.staking.getEntity(stakeEntityId); + + console.log("Before unstake:"); + console.log(` Staker Shares: ${summaryBeforeUnstake.shares.toString()}`); + console.log( + ` Staker Cost Basis: ${(Number(summaryBeforeUnstake.costBasis) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Staker Current Value: ${(Number(summaryBeforeUnstake.currentValue) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Entity Active Pool: ${(Number(entityBeforeUnstake.activeRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Entity Total Shares: ${entityBeforeUnstake.totalShares.toString()}`, + ); + console.log( + `\n Max Unstake Amount: ${(Number(unstakeInfo.maxVana) / 1e18).toLocaleString()} VANA`, + ); + console.log(` Max Shares to Burn: ${unstakeInfo.maxShares.toString()}`); + console.log(` In Bonding Period: ${unstakeInfo.isInBondingPeriod}`); + + // Perform unstake with max amount + console.log( + `\nUnstaking ${(Number(unstakeInfo.maxVana) / 1e18).toLocaleString()} VANA from entity #${stakeEntityId}...`, + ); + + // Estimate gas for unstake transaction + const stakingAddress = getContractAddress( + mokshaTestnet.id, + "VanaPoolStaking", + ); + const stakingAbi = getAbi("VanaPoolStaking"); + + const estimatedGas = await publicClient.estimateContractGas({ + address: stakingAddress, + abi: stakingAbi, + functionName: "unstakeVana", + args: [stakeEntityId, unstakeInfo.maxVana, unstakeInfo.maxShares], + account: stakerAddress, + }); + const gasWithBuffer = (estimatedGas * 12n) / 10n; // 1.2x estimated gas + console.log( + `Estimated gas: ${estimatedGas}, with 1.2x buffer: ${gasWithBuffer}`, + ); + + const unstakeTxHash = await vana.staking.unstake( + { + entityId: stakeEntityId, + amount: unstakeInfo.maxVana, + maxShares: unstakeInfo.maxShares, + }, + { gas: gasWithBuffer }, + ); + console.log(`Transaction hash: ${unstakeTxHash}`); + + // Wait for transaction confirmation + console.log("Waiting for transaction confirmation..."); + const unstakeReceipt = await publicClient.waitForTransactionReceipt({ + hash: unstakeTxHash, + }); + console.log(`Transaction confirmed in block ${unstakeReceipt.blockNumber}`); + + // Get state after unstake + const summaryAfterUnstake = await vana.staking.getStakerSummary( + stakerAddress, + stakeEntityId, + ); + const entityAfterUnstake = await vana.staking.getEntity(stakeEntityId); + + console.log("\nAfter unstake:"); + console.log(` Staker Shares: ${summaryAfterUnstake.shares.toString()}`); + console.log( + ` Staker Cost Basis: ${(Number(summaryAfterUnstake.costBasis) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Staker Current Value: ${(Number(summaryAfterUnstake.currentValue) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Entity Active Pool: ${(Number(entityAfterUnstake.activeRewardPool) / 1e18).toLocaleString()} VANA`, + ); + console.log( + ` Entity Total Shares: ${entityAfterUnstake.totalShares.toString()}`, + ); + + // Verify unstake changes + console.log("\n=== Unstake Verification ===\n"); + + const sharesUnstaked = + summaryBeforeUnstake.shares - summaryAfterUnstake.shares; + const costBasisReduced = + summaryBeforeUnstake.costBasis - summaryAfterUnstake.costBasis; + const currentValueReduced = + summaryBeforeUnstake.currentValue - summaryAfterUnstake.currentValue; + const entityPoolReduced = + entityBeforeUnstake.activeRewardPool - + entityAfterUnstake.activeRewardPool; + const entitySharesReduced = + entityBeforeUnstake.totalShares - entityAfterUnstake.totalShares; + + console.log( + `Unstake Amount Requested: ${(Number(unstakeInfo.maxVana) / 1e18).toLocaleString()} VANA`, + ); + console.log(`Shares Burned: ${sharesUnstaked.toString()}`); + console.log( + `Cost Basis Reduced: ${(Number(costBasisReduced) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Current Value Reduced: ${(Number(currentValueReduced) / 1e18).toLocaleString()} VANA`, + ); + console.log( + `Entity Pool Reduced: ${(Number(entityPoolReduced) / 1e18).toLocaleString()} VANA`, + ); + console.log(`Entity Shares Reduced: ${entitySharesReduced.toString()}`); + + // Validate + // Note: Values may differ slightly due to reward accrual between fetching unstakeInfo and execution + // Use approximate comparison (within 0.1% tolerance) for value checks + const tolerance = unstakeInfo.maxVana / 1000n; // 0.1% tolerance + + const sharesMatch = sharesUnstaked === unstakeInfo.maxShares; + const unstakeSharesMatchEntity = sharesUnstaked === entitySharesReduced; + + // Helper for approximate comparison + const approxEqual = (a: bigint, b: bigint, tol: bigint): boolean => { + const diff = a > b ? a - b : b - a; + return diff <= tol; + }; + + // During bonding: cost basis reduced by unstake amount, rewards forfeited + // After bonding: current value reduced by unstake amount + let valueReductionCorrect: boolean; + if (unstakeInfo.isInBondingPeriod) { + // Cost basis should be reduced by approximately the unstake amount + valueReductionCorrect = approxEqual( + costBasisReduced, + unstakeInfo.maxVana, + tolerance, + ); + console.log(`\nBonding Period Validation:`); + console.log( + ` ✓ Cost basis reduced by unstake amount: ${valueReductionCorrect ? "PASS" : "FAIL"}`, + ); + if (!valueReductionCorrect) { + console.log( + ` Expected: ~${(Number(unstakeInfo.maxVana) / 1e18).toLocaleString()} VANA, Got: ${(Number(costBasisReduced) / 1e18).toLocaleString()} VANA`, + ); + } + } else { + // Current value should be reduced by approximately the unstake amount + valueReductionCorrect = approxEqual( + currentValueReduced, + unstakeInfo.maxVana, + tolerance, + ); + console.log(`\nPost-Bonding Validation:`); + console.log( + ` ✓ Current value reduced by unstake amount: ${valueReductionCorrect ? "PASS" : "FAIL"}`, + ); + if (!valueReductionCorrect) { + console.log( + ` Expected: ~${(Number(unstakeInfo.maxVana) / 1e18).toLocaleString()} VANA, Got: ${(Number(currentValueReduced) / 1e18).toLocaleString()} VANA`, + ); + } + } + + console.log( + ` ✓ Shares burned match max shares: ${sharesMatch ? "PASS" : "FAIL"}`, + ); + if (!sharesMatch) { + console.log( + ` Expected: ${unstakeInfo.maxShares.toString()}, Got: ${sharesUnstaked.toString()}`, + ); + } + console.log( + ` ✓ Staker shares match entity shares reduction: ${unstakeSharesMatchEntity ? "PASS" : "FAIL"}`, + ); + + // Verify realized rewards increased if after bonding period + if (!unstakeInfo.isInBondingPeriod) { + const realizedRewardsIncrease = + summaryAfterUnstake.realizedRewards - + summaryBeforeUnstake.realizedRewards; + console.log( + ` Realized Rewards Increase: ${(Number(realizedRewardsIncrease) / 1e18).toLocaleString()} VANA`, + ); + } + + if (valueReductionCorrect && sharesMatch && unstakeSharesMatchEntity) { + console.log("\n✅ All unstake validations passed!"); + } else { + console.log( + "\n❌ Some unstake validations failed. Check the results above.", + ); + } + } else { + console.log("\n=== Stake Test ===\n"); + console.log("Skipped: Set PRIVATE_KEY in .env to test staking"); + } +} + +main().catch(console.error); diff --git a/examples/vana-sdk-demo/tsconfig.json b/examples/vana-sdk-demo/tsconfig.json new file mode 100644 index 00000000..9f43d8fe --- /dev/null +++ b/examples/vana-sdk-demo/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/vana-sdk/src/controllers/staking.ts b/packages/vana-sdk/src/controllers/staking.ts new file mode 100644 index 00000000..0362b41f --- /dev/null +++ b/packages/vana-sdk/src/controllers/staking.ts @@ -0,0 +1,928 @@ +/** + * Provides staking functionality for the VanaPool protocol. + * + * @remarks + * This controller handles interactions with VanaPool staking contracts, + * allowing users to query staking information such as total VANA staked, + * entity information, and staker positions. + * + * @category Controllers + * @module StakingController + */ + +import type { ControllerContext } from "../types/controller-context"; +import type { TransactionOptions } from "../types/operations"; +import { BaseController } from "./base"; +import { getContract, parseEther } from "viem"; +import type { Address, Hash } from "viem"; +import { getContractAddress } from "../generated/addresses"; +import { getAbi } from "../generated/abi"; +import { BlockchainError } from "../errors"; + +/** + * Information about a staking entity in the VanaPool protocol. + */ +export interface EntityInfo { + entityId: bigint; + ownerAddress: Address; + status: number; + name: string; + maxAPY: bigint; + lockedRewardPool: bigint; + activeRewardPool: bigint; + totalShares: bigint; + lastUpdateTimestamp: bigint; +} + +/** + * Information about a staker's position in an entity. + */ +export interface StakerEntityInfo { + shares: bigint; + costBasis: bigint; + rewardEligibilityTimestamp: bigint; + realizedRewards: bigint; + vestedRewards: bigint; +} + +/** + * Comprehensive staking summary for a staker in an entity. + */ +export interface StakerEntitySummary { + /** Number of shares owned by the staker */ + shares: bigint; + /** Cost basis - the original VANA amount staked */ + costBasis: bigint; + /** Current value of the staker's shares in VANA */ + currentValue: bigint; + /** Timestamp when rewards become eligible */ + rewardEligibilityTimestamp: bigint; + /** Remaining bonding time in seconds (0 if bonding period has passed) */ + remainingBondingTime: bigint; + /** Whether the staker is still in the bonding period */ + isInBondingPeriod: boolean; + /** Vested rewards that can be claimed without penalty */ + vestedRewards: bigint; + /** Unvested rewards (pending interest = currentValue - costBasis) */ + unvestedRewards: bigint; + /** Realized/withdrawn rewards (already claimed) */ + realizedRewards: bigint; + /** Total earned rewards (includes vested, unvested, and realized) */ + earnedRewards: bigint; +} + +/** + * Controller for VanaPool staking operations. + * + * @remarks + * Provides methods to query staking information from the VanaPool contracts. + * This includes total VANA staked across the protocol, entity information, + * and individual staker positions. + * + * @example + * ```typescript + * // Get total VANA staked in the protocol + * const totalStaked = await vana.staking.getTotalVanaStaked(); + * console.log(`Total staked: ${totalStaked} wei`); + * + * // Get entity information + * const entity = await vana.staking.getEntity(1n); + * console.log(`Entity name: ${entity.name}`); + * ``` + * + * @category Controllers + */ +export class StakingController extends BaseController { + constructor(context: ControllerContext) { + super(context); + } + + /** + * Gets the chain ID from context. + */ + private getChainId(): number { + const chainId = + this.context.walletClient?.chain?.id ?? + this.context.publicClient.chain?.id; + if (!chainId) { + throw new Error("Chain ID not available"); + } + return chainId; + } + + /** + * Gets the VanaPoolEntity contract instance. + */ + private getEntityContract() { + const chainId = this.getChainId(); + return getContract({ + address: getContractAddress(chainId, "VanaPoolEntity"), + abi: getAbi("VanaPoolEntity"), + client: this.context.publicClient, + }); + } + + /** + * Gets the VanaPoolStaking contract instance. + */ + private getStakingContract() { + const chainId = this.getChainId(); + return getContract({ + address: getContractAddress(chainId, "VanaPoolStaking"), + abi: getAbi("VanaPoolStaking"), + client: this.context.publicClient, + }); + } + + /** + * Gets the total amount of VANA staked in the VanaPool protocol or a specific entity. + * + * @remarks + * When called without an entityId, this retrieves the sum of activeRewardPool + * across all active entities in the VanaPool protocol. + * When called with an entityId, this returns the activeRewardPool for that specific entity. + * The value is returned in wei (10^18 = 1 VANA). + * + * @param entityId - Optional entity ID to get staked amount for a specific entity + * @returns The total amount of VANA staked in wei + * + * @example + * ```typescript + * // Get total staked across all entities + * const totalStaked = await vana.staking.getTotalVanaStaked(); + * console.log(`Total staked: ${Number(totalStaked) / 1e18} VANA`); + * + * // Get staked amount for a specific entity + * const entityStaked = await vana.staking.getTotalVanaStaked(1n); + * console.log(`Entity 1 staked: ${Number(entityStaked) / 1e18} VANA`); + * ``` + */ + async getTotalVanaStaked(entityId?: bigint): Promise { + const entityContract = this.getEntityContract(); + + // If entityId is provided, return the activeRewardPool for that specific entity + if (entityId !== undefined) { + const entity = await entityContract.read.entities([entityId]); + return entity.activeRewardPool; + } + + // Otherwise, sum up the activeRewardPool from all active entities + const activeEntityIds = await entityContract.read.activeEntitiesValues(); + + let totalStaked = 0n; + for (const id of activeEntityIds) { + const entity = await entityContract.read.entities([id]); + totalStaked += entity.activeRewardPool; + } + + return totalStaked; + } + + /** + * Gets information about a specific staking entity. + * + * @param entityId - The ID of the entity to query + * @returns The entity information including name, APY, shares, and reward pools + * + * @example + * ```typescript + * const entity = await vana.staking.getEntity(1n); + * console.log(`Entity: ${entity.name}`); + * console.log(`Total shares: ${entity.totalShares}`); + * console.log(`Max APY: ${Number(entity.maxAPY) / 100}%`); + * ``` + */ + async getEntity(entityId: bigint): Promise { + const entityContract = this.getEntityContract(); + + const result = await entityContract.read.entities([entityId]); + + return { + entityId: result.entityId, + ownerAddress: result.ownerAddress as Address, + status: result.status, + name: result.name, + maxAPY: result.maxAPY, + lockedRewardPool: result.lockedRewardPool, + activeRewardPool: result.activeRewardPool, + totalShares: result.totalShares, + lastUpdateTimestamp: result.lastUpdateTimestamp, + }; + } + + /** + * Gets the total number of staking entities in the protocol. + * + * @returns The count of entities + * + * @example + * ```typescript + * const count = await vana.staking.getEntitiesCount(); + * console.log(`Total entities: ${count}`); + * ``` + */ + async getEntitiesCount(): Promise { + const entityContract = this.getEntityContract(); + return entityContract.read.entitiesCount(); + } + + /** + * Gets the IDs of all active staking entities. + * + * @returns Array of active entity IDs + * + * @example + * ```typescript + * const activeIds = await vana.staking.getActiveEntities(); + * console.log(`Active entities: ${activeIds.join(', ')}`); + * ``` + */ + async getActiveEntities(): Promise { + const entityContract = this.getEntityContract(); + return entityContract.read.activeEntitiesValues(); + } + + /** + * Gets a staker's position in a specific entity. + * + * @param staker - The address of the staker + * @param entityId - The ID of the entity + * @returns The staker's position information + * + * @example + * ```typescript + * const position = await vana.staking.getStakerPosition( + * '0x742d35...', + * 1n + * ); + * console.log(`Shares: ${position.shares}`); + * console.log(`Rewards: ${position.realizedRewards}`); + * ``` + */ + async getStakerPosition( + staker: Address, + entityId: bigint, + ): Promise { + const stakingContract = this.getStakingContract(); + + const result = await stakingContract.read.stakerEntities([ + staker, + entityId, + ]); + + return { + shares: result.shares, + costBasis: result.costBasis, + rewardEligibilityTimestamp: result.rewardEligibilityTimestamp, + realizedRewards: result.realizedRewards, + vestedRewards: result.vestedRewards, + }; + } + + /** + * Gets the earned rewards for a staker in an entity. + * + * @param staker - The address of the staker + * @param entityId - The ID of the entity + * @returns The earned rewards amount in wei + * + * @example + * ```typescript + * const rewards = await vana.staking.getEarnedRewards( + * '0x742d35...', + * 1n + * ); + * console.log(`Earned: ${Number(rewards) / 1e18} VANA`); + * ``` + */ + async getEarnedRewards(staker: Address, entityId: bigint): Promise { + const stakingContract = this.getStakingContract(); + return stakingContract.read.getEarnedRewards([staker, entityId]); + } + + /** + * Gets the minimum stake amount required to stake. + * + * @returns The minimum stake amount in wei + * + * @example + * ```typescript + * const minStake = await vana.staking.getMinStakeAmount(); + * console.log(`Minimum stake: ${Number(minStake) / 1e18} VANA`); + * ``` + */ + async getMinStakeAmount(): Promise { + const stakingContract = this.getStakingContract(); + return stakingContract.read.minStakeAmount(); + } + + /** + * Gets the count of active stakers in the protocol. + * + * @returns The number of active stakers + * + * @example + * ```typescript + * const count = await vana.staking.getActiveStakersCount(); + * console.log(`Active stakers: ${count}`); + * ``` + */ + async getActiveStakersCount(): Promise { + const stakingContract = this.getStakingContract(); + return stakingContract.read.activeStakersListCount(); + } + + /** + * Gets the bonding period for staking. + * + * @returns The bonding period in seconds + * + * @example + * ```typescript + * const bondingPeriod = await vana.staking.getBondingPeriod(); + * console.log(`Bonding period: ${bondingPeriod / 86400n} days`); + * ``` + */ + async getBondingPeriod(): Promise { + const stakingContract = this.getStakingContract(); + return stakingContract.read.bondingPeriod(); + } + + /** + * Gets the total distributed rewards for a specific entity from the subgraph. + * + * @remarks + * This queries the VanaPool subgraph to retrieve the cumulative `totalDistributedRewards` + * for a staking entity. This value represents the sum of all rewards that have been + * processed (moved from lockedRewardPool to activeRewardPool) minus any forfeited + * rewards that were returned. + * + * Requires a configured `subgraphUrl` in the Vana constructor options. + * + * @param entityId - The ID of the entity to query + * @param options - Optional configuration including custom subgraph URL + * @returns The total distributed rewards in wei + * @throws {BlockchainError} When subgraph URL is not configured or query fails + * + * @example + * ```typescript + * const totalRewards = await vana.staking.getTotalDistributedRewards(1n); + * const totalRewardsVana = Number(totalRewards) / 1e18; + * console.log(`Total distributed rewards: ${totalRewardsVana.toLocaleString()} VANA`); + * ``` + */ + async getTotalDistributedRewards( + entityId: bigint, + options: { subgraphUrl?: string } = {}, + ): Promise { + const graphqlEndpoint = options.subgraphUrl ?? this.context.subgraphUrl; + + if (!graphqlEndpoint) { + throw new BlockchainError( + "subgraphUrl is required. Please provide a valid subgraph endpoint or configure it in Vana constructor.", + ); + } + + const query = ` + query GetStakingEntityRewards($entityId: ID!) { + stakingEntity(id: $entityId) { + totalDistributedRewards + } + } + `; + + const response = await fetch(graphqlEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query, + variables: { + entityId: entityId.toString(), + }, + }), + }); + + if (!response.ok) { + throw new BlockchainError( + `Subgraph query failed with status ${response.status}`, + ); + } + + const result = (await response.json()) as { + data?: { stakingEntity?: { totalDistributedRewards: string } }; + errors?: Array<{ message: string }>; + }; + + if (result.errors && result.errors.length > 0) { + throw new BlockchainError( + `Subgraph query error: ${result.errors[0].message}`, + ); + } + + if (!result.data?.stakingEntity) { + throw new BlockchainError(`Staking entity ${entityId} not found`); + } + + return BigInt(result.data.stakingEntity.totalDistributedRewards); + } + + /** + * Gets a comprehensive staking summary for a staker in an entity. + * + * @remarks + * This method aggregates all relevant staking information for a staker in a single call, + * including staked amount, current value, bonding status, and all reward types. + * + * Reward breakdown: + * - `earnedRewards` = pendingInterest + vestedRewards + realizedRewards + * - `pendingInterest` = currentValue - costBasis (unvested appreciation) + * - `vestedRewards` = rewards that have vested but not yet withdrawn + * - `realizedRewards` = rewards already withdrawn during unstakes + * + * @param staker - The address of the staker + * @param entityId - The ID of the entity + * @returns A comprehensive summary of the staker's position + * + * @example + * ```typescript + * const summary = await vana.staking.getStakerSummary('0x742d35...', 1n); + * console.log(`Total staked: ${Number(summary.totalStaked) / 1e18} VANA`); + * console.log(`Current value: ${Number(summary.currentValue) / 1e18} VANA`); + * console.log(`In bonding period: ${summary.isInBondingPeriod}`); + * console.log(`Remaining bonding time: ${Number(summary.remainingBondingTime) / 86400} days`); + * console.log(`Vested rewards: ${Number(summary.vestedRewards) / 1e18} VANA`); + * console.log(`Unvested rewards: ${Number(summary.unvestedRewards) / 1e18} VANA`); + * console.log(`Realized rewards: ${Number(summary.realizedRewards) / 1e18} VANA`); + * console.log(`Total earned: ${Number(summary.earnedRewards) / 1e18} VANA`); + * ``` + */ + async getStakerSummary( + staker: Address, + entityId: bigint, + ): Promise { + const chainId = this.getChainId(); + const stakingAddress = getContractAddress(chainId, "VanaPoolStaking"); + const entityAddress = getContractAddress(chainId, "VanaPoolEntity"); + const stakingAbi = getAbi("VanaPoolStaking"); + const entityAbi = getAbi("VanaPoolEntity"); + + // Get latest block first to ensure all reads are from the same block + const block = await this.context.publicClient.getBlock(); + const blockNumber = block.number; + + // Batch all contract reads into a single multicall RPC request at the same block + const multicallResults = await this.context.publicClient.multicall({ + contracts: [ + { + address: stakingAddress, + abi: stakingAbi, + functionName: "stakerEntities", + args: [staker, entityId], + }, + { + address: stakingAddress, + abi: stakingAbi, + functionName: "getEarnedRewards", + args: [staker, entityId], + }, + { + address: entityAddress, + abi: entityAbi, + functionName: "entityShareToVana", + args: [entityId], + }, + ], + blockNumber, + }); + + const [positionResult, earnedRewardsResult, shareToVanaResult] = + multicallResults; + + if ( + positionResult.status === "failure" || + earnedRewardsResult.status === "failure" || + shareToVanaResult.status === "failure" + ) { + throw new BlockchainError( + "Failed to fetch staker summary: one or more contract calls failed", + ); + } + + const position = positionResult.result as { + shares: bigint; + costBasis: bigint; + rewardEligibilityTimestamp: bigint; + realizedRewards: bigint; + vestedRewards: bigint; + }; + const earnedRewards = earnedRewardsResult.result as bigint; + const shareToVana = shareToVanaResult.result as bigint; + const currentTimestamp = block.timestamp; + + // Calculate remaining bonding time + const eligibilityTimestamp = position.rewardEligibilityTimestamp; + const remainingBondingTime = + eligibilityTimestamp > currentTimestamp + ? eligibilityTimestamp - currentTimestamp + : 0n; + const isInBondingPeriod = remainingBondingTime > 0n; + + // Calculate current value of shares + // entityShareToVana returns the VANA value of 1 share (scaled by 1e18) + const currentValue = (position.shares * shareToVana) / 10n ** 18n; + + // Calculate pending interest (unvested rewards) + // pendingInterest = currentValue - costBasis (if positive) + const unvestedRewards = + currentValue > position.costBasis + ? currentValue - position.costBasis + : 0n; + + return { + shares: position.shares, + costBasis: position.costBasis, + currentValue, + rewardEligibilityTimestamp: eligibilityTimestamp, + remainingBondingTime, + isInBondingPeriod, + vestedRewards: position.vestedRewards, + unvestedRewards, + realizedRewards: position.realizedRewards, + earnedRewards, + }; + } + + /** + * Stakes VANA to an entity in the VanaPool protocol. + * + * @remarks + * This method stakes native VANA tokens to a specified entity. The staker will receive + * shares in proportion to their stake amount. A bonding period applies during which + * rewards cannot be fully claimed without penalty. + * + * Requires a wallet client to be configured in the Vana constructor. + * + * @param params - The staking parameters + * @param params.entityId - The ID of the entity to stake to + * @param params.amount - The amount of VANA to stake (in wei, or as a string like "1.5" for 1.5 VANA) + * @param params.recipient - Optional recipient address for the shares (defaults to the sender) + * @param params.minShares - Optional minimum shares to receive (slippage protection, defaults to 0) + * @returns The transaction hash + * @throws {BlockchainError} When wallet client is not configured + * + * @example + * ```typescript + * // Stake 100 VANA to entity 1 + * const txHash = await vana.staking.stake({ + * entityId: 1n, + * amount: "100", // 100 VANA + * }); + * console.log(`Staked! Transaction: ${txHash}`); + * + * // Stake with slippage protection + * const txHash2 = await vana.staking.stake({ + * entityId: 1n, + * amount: parseEther("50"), // 50 VANA in wei + * minShares: parseEther("49"), // Expect at least 49 shares + * }); + * ``` + */ + async stake( + params: { + entityId: bigint; + amount: bigint | string; + recipient?: Address; + minShares?: bigint; + }, + options?: TransactionOptions, + ): Promise { + this.assertWallet(); + + const chainId = this.getChainId(); + const stakingAddress = getContractAddress(chainId, "VanaPoolStaking"); + const stakingAbi = getAbi("VanaPoolStaking"); + + // Convert amount to bigint if it's a string (e.g., "1.5" -> 1.5 VANA in wei) + const amountWei = + typeof params.amount === "string" + ? parseEther(params.amount) + : params.amount; + + // Get account with fallback to userAddress + const account = + this.context.walletClient.account ?? this.context.userAddress; + const accountAddress = + typeof account === "string" ? account : account.address; + + // Default recipient to the sender's address + const recipient = params.recipient ?? accountAddress; + + // Default minShares to 0 (no slippage protection) + const minShares = params.minShares ?? 0n; + + const txHash = await this.context.walletClient.writeContract({ + address: stakingAddress, + abi: stakingAbi, + functionName: "stake", + args: [params.entityId, recipient, minShares], + value: amountWei, + account, + chain: this.context.walletClient.chain, + ...this.spreadTransactionOptions(options), + }); + + return txHash; + } + + /** + * Gets the maximum amount of VANA that can be unstaked in a single transaction. + * + * @remarks + * This calls the contract's getMaxUnstakeAmount which returns the minimum of: + * 1. The withdrawable VANA (costBasis if in bonding period, shareValue if eligible) + * 2. The entity's activeRewardPool (what the entity has available) + * 3. The treasury balance (what can be paid out) + * + * The limiting factor indicates what's constraining the unstake: + * - 0 = user shares/costBasis + * - 1 = activeRewardPool + * - 2 = treasury + * + * @param staker - The address of the staker + * @param entityId - The ID of the entity + * @returns Object containing maxVana, maxShares, limitingFactor, and isInBondingPeriod + * + * @example + * ```typescript + * const result = await vana.staking.getMaxUnstakeAmount('0x742d35...', 1n); + * console.log(`Max unstake: ${Number(result.maxVana) / 1e18} VANA`); + * console.log(`Max shares: ${result.maxShares}`); + * console.log(`In bonding period: ${result.isInBondingPeriod}`); + * ``` + */ + async getMaxUnstakeAmount( + staker: Address, + entityId: bigint, + ): Promise<{ + maxVana: bigint; + maxShares: bigint; + limitingFactor: number; + isInBondingPeriod: boolean; + }> { + const stakingContract = this.getStakingContract(); + + const result = await stakingContract.read.getMaxUnstakeAmount([ + staker, + entityId, + ]); + + return { + maxVana: result[0], + maxShares: result[1], + limitingFactor: Number(result[2]), + isInBondingPeriod: result[3], + }; + } + + /** + * Computes the new bonding period end timestamp after adding stake. + * + * @remarks + * When a staker adds more stake to an existing position, the reward eligibility timestamp + * is recalculated as a weighted average of the existing and new positions. This function + * allows you to preview what the new eligibility timestamp would be without executing + * the stake transaction. + * + * The formula used (matching the contract): + * ``` + * newEligibility = (existingShares * existingEligibility + newShares * newEligibility) / totalShares + * ``` + * + * Where `newEligibility` for the incoming stake is `currentTimestamp + bondingPeriod`. + * + * @param params - The parameters for computing the new bonding period + * @param params.staker - The address of the staker + * @param params.entityId - The ID of the entity + * @param params.stakeAmount - The amount of VANA to stake (in wei) + * @returns Object containing the new eligibility timestamp and related info + * + * @example + * ```typescript + * // Preview bonding period after staking 100 VANA + * const preview = await vana.staking.computeNewBondingPeriod({ + * staker: '0x742d35...', + * entityId: 1n, + * stakeAmount: parseEther("100"), + * }); + * console.log(`New eligibility: ${new Date(Number(preview.newEligibilityTimestamp) * 1000)}`); + * console.log(`Remaining bonding time: ${Number(preview.newRemainingBondingTime) / 86400} days`); + * ``` + */ + async computeNewBondingPeriod(params: { + staker: Address; + entityId: bigint; + stakeAmount: bigint; + }): Promise<{ + /** The new reward eligibility timestamp after staking */ + newEligibilityTimestamp: bigint; + /** Remaining bonding time in seconds after staking */ + newRemainingBondingTime: bigint; + /** Current eligibility timestamp (before staking) */ + currentEligibilityTimestamp: bigint; + /** Current remaining bonding time (before staking) */ + currentRemainingBondingTime: bigint; + /** Current shares held by staker */ + currentShares: bigint; + /** Estimated new shares to be received */ + estimatedNewShares: bigint; + /** Total shares after staking */ + totalSharesAfter: bigint; + /** The bonding period duration in seconds */ + bondingPeriodDuration: bigint; + /** Current block timestamp used for calculation */ + currentTimestamp: bigint; + }> { + const chainId = this.getChainId(); + const stakingAddress = getContractAddress(chainId, "VanaPoolStaking"); + const entityAddress = getContractAddress(chainId, "VanaPoolEntity"); + const stakingAbi = getAbi("VanaPoolStaking"); + const entityAbi = getAbi("VanaPoolEntity"); + + // Get latest block first to ensure all reads are from the same block + const block = await this.context.publicClient.getBlock(); + const blockNumber = block.number; + const currentTimestamp = block.timestamp; + + // Batch all contract reads into a single multicall RPC request at the same block + const multicallResults = await this.context.publicClient.multicall({ + contracts: [ + { + address: stakingAddress, + abi: stakingAbi, + functionName: "stakerEntities", + args: [params.staker, params.entityId], + }, + { + address: stakingAddress, + abi: stakingAbi, + functionName: "bondingPeriod", + args: [], + }, + { + address: entityAddress, + abi: entityAbi, + functionName: "vanaToEntityShare", + args: [params.entityId], + }, + ], + blockNumber, + }); + + const [positionResult, bondingPeriodResult, vanaToShareResult] = + multicallResults; + + if ( + positionResult.status === "failure" || + bondingPeriodResult.status === "failure" || + vanaToShareResult.status === "failure" + ) { + throw new BlockchainError( + "Failed to compute new bonding period: one or more contract calls failed", + ); + } + + const position = positionResult.result as { + shares: bigint; + costBasis: bigint; + rewardEligibilityTimestamp: bigint; + realizedRewards: bigint; + vestedRewards: bigint; + }; + const bondingPeriodDuration = bondingPeriodResult.result as bigint; + const vanaToShare = vanaToShareResult.result as bigint; + + const currentShares = position.shares; + const currentEligibilityTimestamp = position.rewardEligibilityTimestamp; + + // Calculate current remaining bonding time + const currentRemainingBondingTime = + currentEligibilityTimestamp > currentTimestamp + ? currentEligibilityTimestamp - currentTimestamp + : 0n; + + // Estimate new shares: newShares = (stakeAmount * vanaToShare) / 1e18 + const estimatedNewShares = (params.stakeAmount * vanaToShare) / 10n ** 18n; + + // Calculate new eligibility timestamp using weighted average formula + // newEligibility = (existingShares * existingEligibility + newShares * (currentTime + bondingPeriod)) / totalShares + const totalSharesAfter = currentShares + estimatedNewShares; + const newStakeEligibility = currentTimestamp + bondingPeriodDuration; + + let newEligibilityTimestamp: bigint; + if (currentShares === 0n) { + // First stake: eligibility is simply current time + bonding period + newEligibilityTimestamp = newStakeEligibility; + } else { + // Weighted average of existing and new positions + newEligibilityTimestamp = + (currentShares * currentEligibilityTimestamp + + estimatedNewShares * newStakeEligibility) / + totalSharesAfter; + } + + // Calculate new remaining bonding time + const newRemainingBondingTime = + newEligibilityTimestamp > currentTimestamp + ? newEligibilityTimestamp - currentTimestamp + : 0n; + + return { + newEligibilityTimestamp, + newRemainingBondingTime, + currentEligibilityTimestamp, + currentRemainingBondingTime, + currentShares, + estimatedNewShares, + totalSharesAfter, + bondingPeriodDuration, + currentTimestamp, + }; + } + + /** + * Unstakes VANA from an entity in the VanaPool protocol. + * + * @remarks + * This method unstakes native VANA tokens from a specified entity. The amount + * that can be unstaked depends on whether the staker is in the bonding period: + * + * - **During bonding period**: Only cost basis can be withdrawn; rewards are forfeited + * - **After bonding period**: Full current value (cost basis + rewards) can be withdrawn + * + * Use `getMaxUnstakeAmount` to determine the maximum amount that can be unstaked. + * + * Requires a wallet client to be configured in the Vana constructor. + * + * @param params - The unstaking parameters + * @param params.entityId - The ID of the entity to unstake from + * @param params.amount - The amount of VANA to unstake (in wei, or as a string like "1.5" for 1.5 VANA) + * @param params.maxShares - Maximum shares to burn for slippage protection (defaults to 0, no protection) + * @returns The transaction hash + * @throws {BlockchainError} When wallet client is not configured + * + * @example + * ```typescript + * // Get max unstake amount first + * const maxUnstake = await vana.staking.getMaxUnstakeAmount(address, 1n); + * + * // Unstake the maximum amount with slippage protection + * const txHash = await vana.staking.unstake({ + * entityId: 1n, + * amount: maxUnstake.maxVana, + * maxShares: maxUnstake.maxShares, + * }); + * console.log(`Unstaked! Transaction: ${txHash}`); + * ``` + */ + async unstake( + params: { + entityId: bigint; + amount: bigint | string; + maxShares?: bigint; + }, + options?: TransactionOptions, + ): Promise { + this.assertWallet(); + + const chainId = this.getChainId(); + const stakingAddress = getContractAddress(chainId, "VanaPoolStaking"); + const stakingAbi = getAbi("VanaPoolStaking"); + + // Convert amount to bigint if it's a string (e.g., "1.5" -> 1.5 VANA in wei) + const amountWei = + typeof params.amount === "string" + ? parseEther(params.amount) + : params.amount; + + // Default maxShares to 0 (no slippage protection) + const maxShares = params.maxShares ?? 0n; + + // Get account with fallback to userAddress + const account = + this.context.walletClient.account ?? this.context.userAddress; + + const txHash = await this.context.walletClient.writeContract({ + address: stakingAddress, + abi: stakingAbi, + functionName: "unstakeVana", + args: [params.entityId, amountWei, maxShares], + account, + chain: this.context.walletClient.chain, + ...this.spreadTransactionOptions(options), + }); + + return txHash; + } +} diff --git a/packages/vana-sdk/src/core.ts b/packages/vana-sdk/src/core.ts index cb8b6d5c..8ab0dba6 100644 --- a/packages/vana-sdk/src/core.ts +++ b/packages/vana-sdk/src/core.ts @@ -27,6 +27,7 @@ import { SchemaController } from "./controllers/schemas"; import { ServerController } from "./controllers/server"; import { ProtocolController } from "./controllers/protocol"; import { OperationsController } from "./controllers/operations"; +import { StakingController } from "./controllers/staking"; import { StorageManager } from "./storage"; import { createWalletClient, createPublicClient, http } from "viem"; import type { @@ -176,6 +177,9 @@ export class VanaCore { /** Offers low-level access to Vana protocol smart contracts. */ public readonly protocol: ProtocolController; + /** Provides VanaPool staking information and operations. */ + public readonly staking: StakingController; + /** Handles environment-specific operations like encryption and file systems. */ protected platform: VanaPlatformAdapter; @@ -400,6 +404,7 @@ export class VanaCore { this.operations = new OperationsController(sharedContext); this.server = new ServerController(sharedContext); this.protocol = new ProtocolController(sharedContext); + this.staking = new StakingController(sharedContext); } /** diff --git a/packages/vana-sdk/src/generated/abi/VanaPoolEntityImplementation.ts b/packages/vana-sdk/src/generated/abi/VanaPoolEntityImplementation.ts index ea2cdc7c..eb1f2956 100644 --- a/packages/vana-sdk/src/generated/abi/VanaPoolEntityImplementation.ts +++ b/packages/vana-sdk/src/generated/abi/VanaPoolEntityImplementation.ts @@ -3,16 +3,16 @@ // // VanaPoolEntity Implementation Contract // -// Generated: 2025-10-30T23:21:22.979Z -// Network: Vana (Chain ID: 1480) +// Generated: 2026-01-05T17:36:48.917Z +// Network: Moksha Testnet (Chain ID: 14800) // // Proxy Address: // 0x44f20490A82e1f1F1cC25Dd3BA8647034eDdce30 -// https://vanascan.io/address/0x44f20490A82e1f1F1cC25Dd3BA8647034eDdce30 +// https://moksha.vanascan.io/address/0x44f20490A82e1f1F1cC25Dd3BA8647034eDdce30 // // Implementation Address: -// 0x150E238c35537715Ec92D551FCE03b756b4bEAf9 -// https://vanascan.io/address/0x150E238c35537715Ec92D551FCE03b756b4bEAf9 +// 0x7a094F82d4a5BEDF58d2970841933EED8F4d5068 +// https://moksha.vanascan.io/address/0x7a094F82d4a5BEDF58d2970841933EED8F4d5068 export const VanaPoolEntityABI = [ { @@ -268,6 +268,25 @@ export const VanaPoolEntityABI = [ name: "EntityUpdated", type: "event", }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "entityId", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "ForfeitedRewardsReturned", + type: "event", + }, { anonymous: false, inputs: [ @@ -965,6 +984,24 @@ export const VanaPoolEntityABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { + internalType: "uint256", + name: "entityId", + type: "uint256", + }, + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "returnForfeitedRewards", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { diff --git a/packages/vana-sdk/src/generated/abi/VanaPoolStakingImplementation.ts b/packages/vana-sdk/src/generated/abi/VanaPoolStakingImplementation.ts index b537e9d7..0f609b62 100644 --- a/packages/vana-sdk/src/generated/abi/VanaPoolStakingImplementation.ts +++ b/packages/vana-sdk/src/generated/abi/VanaPoolStakingImplementation.ts @@ -3,16 +3,16 @@ // // VanaPoolStaking Implementation Contract // -// Generated: 2025-10-30T23:21:17.540Z -// Network: Vana (Chain ID: 1480) +// Generated: 2026-01-05T17:36:46.222Z +// Network: Moksha Testnet (Chain ID: 14800) // // Proxy Address: // 0x641C18E2F286c86f96CE95C8ec1EB9fC0415Ca0e -// https://vanascan.io/address/0x641C18E2F286c86f96CE95C8ec1EB9fC0415Ca0e +// https://moksha.vanascan.io/address/0x641C18E2F286c86f96CE95C8ec1EB9fC0415Ca0e // // Implementation Address: -// 0x45869CeFA87bfEA07a6cB817687D6C64334c0032 -// https://vanascan.io/address/0x45869CeFA87bfEA07a6cB817687D6C64334c0032 +// 0x8ffd88ADa6E26B4b1585a2F994AA5DaA147053d3 +// https://moksha.vanascan.io/address/0x8ffd88ADa6E26B4b1585a2F994AA5DaA147053d3 export const VanaPoolStakingABI = [ { @@ -118,6 +118,11 @@ export const VanaPoolStakingABI = [ name: "InvalidAmount", type: "error", }, + { + inputs: [], + name: "InvalidBondingPeriod", + type: "error", + }, { inputs: [], name: "InvalidEntity", @@ -179,6 +184,19 @@ export const VanaPoolStakingABI = [ name: "UUPSUnsupportedProxiableUUID", type: "error", }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint256", + name: "newBondingPeriod", + type: "uint256", + }, + ], + name: "BondingPeriodUpdated", + type: "event", + }, { anonymous: false, inputs: [ @@ -426,6 +444,19 @@ export const VanaPoolStakingABI = [ stateMutability: "view", type: "function", }, + { + inputs: [], + name: "MAX_BONDING_PERIOD", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [], name: "UPGRADE_INTERFACE_VERSION", @@ -508,6 +539,106 @@ export const VanaPoolStakingABI = [ stateMutability: "view", type: "function", }, + { + inputs: [], + name: "bondingPeriod", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "staker", + type: "address", + }, + { + internalType: "uint256", + name: "entityId", + type: "uint256", + }, + ], + name: "getAccruingInterest", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "staker", + type: "address", + }, + { + internalType: "uint256", + name: "entityId", + type: "uint256", + }, + ], + name: "getEarnedRewards", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "staker", + type: "address", + }, + { + internalType: "uint256", + name: "entityId", + type: "uint256", + }, + ], + name: "getMaxUnstakeAmount", + outputs: [ + { + internalType: "uint256", + name: "maxVana", + type: "uint256", + }, + { + internalType: "uint256", + name: "maxShares", + type: "uint256", + }, + { + internalType: "uint256", + name: "limitingFactor", + type: "uint256", + }, + { + internalType: "bool", + name: "isInBondingPeriod", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, { inputs: [ { @@ -680,25 +811,6 @@ export const VanaPoolStakingABI = [ stateMutability: "view", type: "function", }, - { - inputs: [ - { - internalType: "bytes[]", - name: "data", - type: "bytes[]", - }, - ], - name: "multicall", - outputs: [ - { - internalType: "bytes[]", - name: "results", - type: "bytes[]", - }, - ], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [], name: "pause", @@ -836,6 +948,26 @@ export const VanaPoolStakingABI = [ name: "shares", type: "uint256", }, + { + internalType: "uint256", + name: "costBasis", + type: "uint256", + }, + { + internalType: "uint256", + name: "rewardEligibilityTimestamp", + type: "uint256", + }, + { + internalType: "uint256", + name: "realizedRewards", + type: "uint256", + }, + { + internalType: "uint256", + name: "vestedRewards", + type: "uint256", + }, ], internalType: "struct IVanaPoolStaking.StakerEntity", name: "", @@ -907,6 +1039,42 @@ export const VanaPoolStakingABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { + internalType: "uint256", + name: "entityId", + type: "uint256", + }, + { + internalType: "uint256", + name: "vanaAmount", + type: "uint256", + }, + { + internalType: "uint256", + name: "shareAmountMax", + type: "uint256", + }, + ], + name: "unstakeVana", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "newBondingPeriod", + type: "uint256", + }, + ], + name: "updateBondingPeriod", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { diff --git a/packages/vana-sdk/src/generated/event-types.ts b/packages/vana-sdk/src/generated/event-types.ts index 2e805e6c..5c297ea9 100644 --- a/packages/vana-sdk/src/generated/event-types.ts +++ b/packages/vana-sdk/src/generated/event-types.ts @@ -806,6 +806,9 @@ export interface EventArgs { spender: `0x${string}`; value: bigint; }; + BondingPeriodUpdated: { + newBondingPeriod: bigint; + }; Claimed: { teeAddress: `0x${string}`; amount: bigint; @@ -1018,6 +1021,10 @@ export interface EventArgs { url: string; schemaId: bigint; }; + ForfeitedRewardsReturned: { + entityId: bigint; + amount: bigint; + }; GranteeRegistered: { granteeId: bigint; owner: `0x${string}`; diff --git a/packages/vana-sdk/src/generated/eventRegistry.ts b/packages/vana-sdk/src/generated/eventRegistry.ts index 457c95ec..bbe0073e 100644 --- a/packages/vana-sdk/src/generated/eventRegistry.ts +++ b/packages/vana-sdk/src/generated/eventRegistry.ts @@ -3034,6 +3034,24 @@ export const TOPIC_TO_ABIS = /*#__PURE__*/ new Map< }, ] as const, ], + [ + "0x7412027f2d81b758163c26c866f9ce4ab666a843e33bec6153fbc5032edd1bcc" as `0x${string}`, + [ + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint256", + name: "newBondingPeriod", + type: "uint256", + }, + ], + name: "BondingPeriodUpdated", + type: "event", + }, + ] as const, + ], [ "0x750e6bbedd4312ada35caa75e07fc0b85f1a6fc9c675e6962aef846918711097" as `0x${string}`, [ @@ -3442,6 +3460,30 @@ export const TOPIC_TO_ABIS = /*#__PURE__*/ new Map< }, ] as const, ], + [ + "0xa8fdc3ef22baab43fd31cd482a94de0be672c1b5f522294e8b2ce117095bb1e2" as `0x${string}`, + [ + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "entityId", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "ForfeitedRewardsReturned", + type: "event", + }, + ] as const, + ], [ "0xac1cb2ea540715774cd22a890314044f6daf1fb60f81a378e5628ca63efa7110" as `0x${string}`, [ diff --git a/packages/vana-sdk/src/index.browser.ts b/packages/vana-sdk/src/index.browser.ts index 26f0aeac..e002a71c 100644 --- a/packages/vana-sdk/src/index.browser.ts +++ b/packages/vana-sdk/src/index.browser.ts @@ -173,6 +173,12 @@ export { ServerController } from "./controllers/server"; export { ProtocolController } from "./controllers/protocol"; export { SchemaController } from "./controllers/schemas"; export { OperationsController } from "./controllers/operations"; +export { StakingController } from "./controllers/staking"; +export type { + EntityInfo, + StakerEntityInfo, + StakerEntitySummary, +} from "./controllers/staking"; // Contract controller export * from "./contracts/contractController"; diff --git a/packages/vana-sdk/src/index.node.ts b/packages/vana-sdk/src/index.node.ts index f8979c3b..d03743af 100644 --- a/packages/vana-sdk/src/index.node.ts +++ b/packages/vana-sdk/src/index.node.ts @@ -261,6 +261,12 @@ export { ServerController } from "./controllers/server"; export { ProtocolController } from "./controllers/protocol"; export { SchemaController } from "./controllers/schemas"; export { OperationsController } from "./controllers/operations"; +export { StakingController } from "./controllers/staking"; +export type { + EntityInfo, + StakerEntityInfo, + StakerEntitySummary, +} from "./controllers/staking"; // Contract controller export * from "./contracts/contractController"; diff --git a/packages/vana-sdk/src/tests/staking.test.ts b/packages/vana-sdk/src/tests/staking.test.ts new file mode 100644 index 00000000..d68b9fa1 --- /dev/null +++ b/packages/vana-sdk/src/tests/staking.test.ts @@ -0,0 +1,612 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createWalletClient, http, parseEther } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { mokshaTestnet } from "../config/chains"; +import { StakingController } from "../controllers/staking"; +import type { ControllerContext } from "../types/controller-context"; +import { ReadOnlyError, BlockchainError } from "../errors"; +import { mockPlatformAdapter } from "./mocks/platformAdapter"; + +// Mock the config and ABI modules +vi.mock("../generated/addresses", () => ({ + getContractAddress: vi.fn(), + CONTRACT_ADDRESSES: { + 14800: { + VanaPoolStaking: "0x641C18E2F286c86f96CE95C8ec1EB9fC0415Ca0e", + VanaPoolEntity: "0xEntity123456789012345678901234567890", + }, + }, +})); + +vi.mock("../generated/abi", () => ({ + getAbi: vi.fn().mockReturnValue([]), +})); + +// Import the mocked functions +import { getContractAddress } from "../generated/addresses"; +import { getAbi } from "../generated/abi"; + +// Type the mocked functions +const mockGetContractAddress = getContractAddress as ReturnType; +const mockGetAbi = getAbi as ReturnType; + +// Test account +const testAccount = privateKeyToAccount( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", +); + +describe("StakingController", () => { + let controller: StakingController; + let mockContext: ControllerContext; + let mockWalletClient: ReturnType; + let mockPublicClient: { + waitForTransactionReceipt: ReturnType; + getTransactionReceipt: ReturnType; + getBlock: ReturnType; + multicall: ReturnType; + chain: { id: number }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockWalletClient = createWalletClient({ + account: testAccount, + chain: mokshaTestnet, + transport: http("https://rpc.moksha.vana.org"), + }); + + // Mock writeContract method + mockWalletClient.writeContract = vi + .fn() + .mockResolvedValue("0xTransactionHash"); + + mockPublicClient = { + waitForTransactionReceipt: vi.fn().mockResolvedValue({ logs: [] }), + getTransactionReceipt: vi.fn().mockResolvedValue({ + transactionHash: "0xTransactionHash", + blockNumber: 12345n, + gasUsed: 100000n, + status: "success" as const, + logs: [], + }), + getBlock: vi + .fn() + .mockResolvedValue({ number: 12345n, timestamp: BigInt(Date.now()) }), + multicall: vi.fn(), + chain: { id: 14800 }, + }; + + mockContext = { + walletClient: + mockWalletClient as unknown as ControllerContext["walletClient"], + publicClient: + mockPublicClient as unknown as ControllerContext["publicClient"], + userAddress: testAccount.address, + platform: mockPlatformAdapter, + }; + + // Setup default mocks + mockGetContractAddress.mockReturnValue( + "0x641C18E2F286c86f96CE95C8ec1EB9fC0415Ca0e", + ); + mockGetAbi.mockReturnValue([]); + + controller = new StakingController(mockContext); + }); + + describe("stake", () => { + it("should stake VANA with string amount", async () => { + const txHash = await controller.stake({ + entityId: 1n, + amount: "10", + }); + + expect(txHash).toBe("0xTransactionHash"); + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "stake", + args: [1n, testAccount.address, 0n], + value: parseEther("10"), + }), + ); + }); + + it("should stake VANA with bigint amount", async () => { + const amount = parseEther("5"); + const txHash = await controller.stake({ + entityId: 2n, + amount, + }); + + expect(txHash).toBe("0xTransactionHash"); + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "stake", + args: [2n, testAccount.address, 0n], + value: amount, + }), + ); + }); + + it("should stake with custom recipient", async () => { + const recipient = "0x1234567890123456789012345678901234567890" as const; + await controller.stake({ + entityId: 1n, + amount: "1", + recipient, + }); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: [1n, recipient, 0n], + }), + ); + }); + + it("should stake with minShares for slippage protection", async () => { + const minShares = parseEther("9"); + await controller.stake({ + entityId: 1n, + amount: "10", + minShares, + }); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: [1n, testAccount.address, minShares], + }), + ); + }); + + it("should throw error when wallet client is not configured", async () => { + const readOnlyContext = { + ...mockContext, + walletClient: undefined, + }; + const readOnlyController = new StakingController(readOnlyContext); + + await expect( + readOnlyController.stake({ + entityId: 1n, + amount: "10", + }), + ).rejects.toThrow(ReadOnlyError); + }); + }); + + describe("getMaxUnstakeAmount", () => { + it("should return max unstake amount from contract", async () => { + // Mock the contract read + const mockRead = vi.fn().mockResolvedValue([ + parseEther("100"), // maxVana + parseEther("95"), // maxShares + 0n, // limitingFactor (user) + false, // isInBondingPeriod + ]); + + // Mock getContract to return our mock + vi.spyOn(controller as never, "getStakingContract").mockReturnValue({ + read: { + getMaxUnstakeAmount: mockRead, + }, + } as never); + + const result = await controller.getMaxUnstakeAmount( + testAccount.address, + 1n, + ); + + expect(result.maxVana).toBe(parseEther("100")); + expect(result.maxShares).toBe(parseEther("95")); + expect(result.limitingFactor).toBe(0); + expect(result.isInBondingPeriod).toBe(false); + expect(mockRead).toHaveBeenCalledWith([testAccount.address, 1n]); + }); + + it("should handle bonding period scenario", async () => { + const mockRead = vi.fn().mockResolvedValue([ + parseEther("50"), // maxVana (cost basis only during bonding) + parseEther("48"), // maxShares + 0n, // limitingFactor + true, // isInBondingPeriod + ]); + + vi.spyOn(controller as never, "getStakingContract").mockReturnValue({ + read: { + getMaxUnstakeAmount: mockRead, + }, + } as never); + + const result = await controller.getMaxUnstakeAmount( + testAccount.address, + 1n, + ); + + expect(result.isInBondingPeriod).toBe(true); + expect(result.maxVana).toBe(parseEther("50")); + }); + + it("should handle pool-limited scenario", async () => { + const mockRead = vi.fn().mockResolvedValue([ + parseEther("25"), // maxVana (limited by pool) + parseEther("24"), // maxShares + 1n, // limitingFactor (activePool) + false, + ]); + + vi.spyOn(controller as never, "getStakingContract").mockReturnValue({ + read: { + getMaxUnstakeAmount: mockRead, + }, + } as never); + + const result = await controller.getMaxUnstakeAmount( + testAccount.address, + 1n, + ); + + expect(result.limitingFactor).toBe(1); + }); + + it("should handle treasury-limited scenario", async () => { + const mockRead = vi.fn().mockResolvedValue([ + parseEther("10"), // maxVana (limited by treasury) + parseEther("9"), // maxShares + 2n, // limitingFactor (treasury) + false, + ]); + + vi.spyOn(controller as never, "getStakingContract").mockReturnValue({ + read: { + getMaxUnstakeAmount: mockRead, + }, + } as never); + + const result = await controller.getMaxUnstakeAmount( + testAccount.address, + 1n, + ); + + expect(result.limitingFactor).toBe(2); + }); + }); + + describe("unstake", () => { + it("should unstake VANA with bigint amount", async () => { + const amount = parseEther("50"); + const txHash = await controller.unstake({ + entityId: 1n, + amount, + }); + + expect(txHash).toBe("0xTransactionHash"); + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "unstakeVana", + args: [1n, amount, 0n], // Default maxShares is 0 + }), + ); + }); + + it("should unstake VANA with string amount", async () => { + const txHash = await controller.unstake({ + entityId: 1n, + amount: "25.5", + }); + + expect(txHash).toBe("0xTransactionHash"); + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "unstakeVana", + args: [1n, parseEther("25.5"), 0n], + }), + ); + }); + + it("should unstake with maxShares for slippage protection", async () => { + const amount = parseEther("100"); + const maxShares = parseEther("95"); + + await controller.unstake({ + entityId: 1n, + amount, + maxShares, + }); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + args: [1n, amount, maxShares], + }), + ); + }); + + it("should pass transaction options", async () => { + const gas = 500000n; + + await controller.unstake( + { + entityId: 1n, + amount: parseEther("10"), + }, + { gas }, + ); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + gas, + }), + ); + }); + + it("should throw error when wallet client is not configured", async () => { + const readOnlyContext = { + ...mockContext, + walletClient: undefined, + }; + const readOnlyController = new StakingController(readOnlyContext); + + await expect( + readOnlyController.unstake({ + entityId: 1n, + amount: parseEther("10"), + }), + ).rejects.toThrow(ReadOnlyError); + }); + + it("should pass maxFeePerGas and maxPriorityFeePerGas options", async () => { + const options = { + maxFeePerGas: 100n * 10n ** 9n, + maxPriorityFeePerGas: 2n * 10n ** 9n, + }; + + await controller.unstake( + { + entityId: 1n, + amount: parseEther("10"), + }, + options, + ); + + expect(mockWalletClient.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + maxFeePerGas: options.maxFeePerGas, + maxPriorityFeePerGas: options.maxPriorityFeePerGas, + }), + ); + }); + }); + + describe("computeNewBondingPeriod", () => { + const bondingPeriodDuration = 5n * 24n * 60n * 60n; // 5 days in seconds + const currentTimestamp = 1700000000n; // Fixed timestamp for testing + + beforeEach(() => { + mockPublicClient.getBlock.mockResolvedValue({ + number: 12345n, + timestamp: currentTimestamp, + }); + }); + + it("should compute bonding period for first stake (no existing position)", async () => { + // Mock multicall results for first-time staker + mockPublicClient.multicall.mockResolvedValue([ + { + status: "success", + result: { + shares: 0n, + costBasis: 0n, + rewardEligibilityTimestamp: 0n, + realizedRewards: 0n, + vestedRewards: 0n, + }, + }, + { status: "success", result: bondingPeriodDuration }, + { status: "success", result: parseEther("1") }, // 1:1 vanaToShare ratio + ]); + + const result = await controller.computeNewBondingPeriod({ + staker: testAccount.address, + entityId: 1n, + stakeAmount: parseEther("100"), + }); + + // First stake: eligibility = currentTimestamp + bondingPeriod + const expectedEligibility = currentTimestamp + bondingPeriodDuration; + expect(result.newEligibilityTimestamp).toBe(expectedEligibility); + expect(result.newRemainingBondingTime).toBe(bondingPeriodDuration); + expect(result.currentShares).toBe(0n); + expect(result.estimatedNewShares).toBe(parseEther("100")); + expect(result.totalSharesAfter).toBe(parseEther("100")); + expect(result.bondingPeriodDuration).toBe(bondingPeriodDuration); + }); + + it("should compute weighted average bonding period for additional stake", async () => { + const existingShares = parseEther("100"); + // Existing eligibility: 2.5 days from now (half through 5-day bonding period) + const existingEligibility = + currentTimestamp + (5n * 24n * 60n * 60n) / 2n; + + mockPublicClient.multicall.mockResolvedValue([ + { + status: "success", + result: { + shares: existingShares, + costBasis: parseEther("100"), + rewardEligibilityTimestamp: existingEligibility, + realizedRewards: 0n, + vestedRewards: 0n, + }, + }, + { status: "success", result: bondingPeriodDuration }, + { status: "success", result: parseEther("1") }, // 1:1 ratio + ]); + + const newStakeAmount = parseEther("100"); // Same amount as existing + const result = await controller.computeNewBondingPeriod({ + staker: testAccount.address, + entityId: 1n, + stakeAmount: newStakeAmount, + }); + + // Weighted average: (100 * (now+2.5d) + 100 * (now+5d)) / 200 = now + 3.75d + const newStakeEligibility = currentTimestamp + bondingPeriodDuration; + const expectedEligibility = + (existingShares * existingEligibility + + parseEther("100") * newStakeEligibility) / + parseEther("200"); + + expect(result.newEligibilityTimestamp).toBe(expectedEligibility); + expect(result.currentShares).toBe(existingShares); + expect(result.estimatedNewShares).toBe(parseEther("100")); + expect(result.totalSharesAfter).toBe(parseEther("200")); + }); + + it("should handle expired bonding period with additional stake", async () => { + const existingShares = parseEther("100"); + // Eligibility already passed (1 day ago) + const existingEligibility = currentTimestamp - 24n * 60n * 60n; + + mockPublicClient.multicall.mockResolvedValue([ + { + status: "success", + result: { + shares: existingShares, + costBasis: parseEther("100"), + rewardEligibilityTimestamp: existingEligibility, + realizedRewards: 0n, + vestedRewards: 0n, + }, + }, + { status: "success", result: bondingPeriodDuration }, + { status: "success", result: parseEther("1") }, + ]); + + const result = await controller.computeNewBondingPeriod({ + staker: testAccount.address, + entityId: 1n, + stakeAmount: parseEther("100"), + }); + + // Current remaining bonding time should be 0 (already eligible) + expect(result.currentRemainingBondingTime).toBe(0n); + // New remaining time should be > 0 because of new stake + expect(result.newRemainingBondingTime).toBeGreaterThan(0n); + }); + + it("should account for different vanaToShare ratios", async () => { + // 2:1 ratio means 100 VANA = 50 shares + const vanaToShareRatio = parseEther("0.5"); + + mockPublicClient.multicall.mockResolvedValue([ + { + status: "success", + result: { + shares: 0n, + costBasis: 0n, + rewardEligibilityTimestamp: 0n, + realizedRewards: 0n, + vestedRewards: 0n, + }, + }, + { status: "success", result: bondingPeriodDuration }, + { status: "success", result: vanaToShareRatio }, + ]); + + const result = await controller.computeNewBondingPeriod({ + staker: testAccount.address, + entityId: 1n, + stakeAmount: parseEther("100"), + }); + + // 100 VANA * 0.5 ratio = 50 shares + expect(result.estimatedNewShares).toBe(parseEther("50")); + expect(result.totalSharesAfter).toBe(parseEther("50")); + }); + + it("should throw BlockchainError when multicall fails", async () => { + mockPublicClient.multicall.mockResolvedValue([ + { status: "failure", error: new Error("Call failed") }, + { status: "success", result: bondingPeriodDuration }, + { status: "success", result: parseEther("1") }, + ]); + + await expect( + controller.computeNewBondingPeriod({ + staker: testAccount.address, + entityId: 1n, + stakeAmount: parseEther("100"), + }), + ).rejects.toThrow(BlockchainError); + }); + + it("should return correct current timestamp", async () => { + mockPublicClient.multicall.mockResolvedValue([ + { + status: "success", + result: { + shares: 0n, + costBasis: 0n, + rewardEligibilityTimestamp: 0n, + realizedRewards: 0n, + vestedRewards: 0n, + }, + }, + { status: "success", result: bondingPeriodDuration }, + { status: "success", result: parseEther("1") }, + ]); + + const result = await controller.computeNewBondingPeriod({ + staker: testAccount.address, + entityId: 1n, + stakeAmount: parseEther("100"), + }); + + expect(result.currentTimestamp).toBe(currentTimestamp); + }); + + it("should handle large stake amounts with proper weighted average", async () => { + const existingShares = parseEther("100"); + // 1 day remaining in bonding period + const existingEligibility = currentTimestamp + 1n * 24n * 60n * 60n; + + mockPublicClient.multicall.mockResolvedValue([ + { + status: "success", + result: { + shares: existingShares, + costBasis: parseEther("100"), + rewardEligibilityTimestamp: existingEligibility, + realizedRewards: 0n, + vestedRewards: 0n, + }, + }, + { status: "success", result: bondingPeriodDuration }, + { status: "success", result: parseEther("1") }, + ]); + + // Large new stake (10x existing) + const newStakeAmount = parseEther("1000"); + const result = await controller.computeNewBondingPeriod({ + staker: testAccount.address, + entityId: 1n, + stakeAmount: newStakeAmount, + }); + + // With 100 shares at 1 day and 1000 new shares at 5 days, + // weighted average should be much closer to 5 days + const newStakeEligibility = currentTimestamp + bondingPeriodDuration; + const expectedEligibility = + (existingShares * existingEligibility + + parseEther("1000") * newStakeEligibility) / + parseEther("1100"); + + expect(result.newEligibilityTimestamp).toBe(expectedEligibility); + expect(result.totalSharesAfter).toBe(parseEther("1100")); + // New remaining time should be close to 5 days (at least 4.5 days) + expect(result.newRemainingBondingTime).toBeGreaterThanOrEqual( + (9n * 24n * 60n * 60n) / 2n, // 4.5 days + ); + }); + }); +});