Skip to content

Commit

Permalink
feat(staking): add reward system to PR alephium#28's implementation
Browse files Browse the repository at this point in the history
- Add time-weighted reward multipliers to existing staking contract
- Implement pool performance adjustments
- Add compound rewards functionality
- Add comprehensive test coverage
- Integrate with PR alephium#28's staking system

Depends on alephium#28
  • Loading branch information
olisaagbafor committed Dec 19, 2024
1 parent 27d5ba9 commit f6dfe0c
Show file tree
Hide file tree
Showing 4 changed files with 248 additions and 29 deletions.
51 changes: 46 additions & 5 deletions liquid-staking/contracts/staking.ral
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ Contract Staking(
mut startTime: U256,
mut duration: U256,
mut earlyUnstakePenaltyPercent: U256,
mut owner_: Address
mut owner_: Address,
mut poolPerformanceMultiplier: U256
) extends StakingAccountFactory(tokenId, rewardsTokenId, stakingAccountTemplateId), StakingTimeBond(startTime, duration, earlyUnstakePenaltyPercent, owner_) {
////////////////////////
// Events
Expand All @@ -19,13 +20,17 @@ Contract Staking(
event Unstaked(staker: Address, amount: U256)
event ClaimedRewards(staker: Address, amount: U256)
event RewardRateUpdated(rewardRate: U256)
event PoolPerformanceUpdated(oldMultiplier: U256, newMultiplier: U256)
event RewardsCompounded(staker: Address, amount: U256)

////////////////////////
// Error Codes
////////////////////////

enum ErrorCodes {
PoolNotStarted = 0
PoolNotStarted = 0,
NoRewardsToCompound = 1,
ConversionFailed = 2
}

////////////////////////
Expand All @@ -36,10 +41,19 @@ Contract Staking(
let rewardPerTokenPaid = account.getRewardPerTokenPaid()
let currentRewardPerToken = calculateRewardPerToken()
let oldRewards = account.getRewards()
let stakeDuration = blockTimeStamp!() - account.getStakingStartTime()

let earnedRewards = ((staked * (currentRewardPerToken - rewardPerTokenPaid)) / 1e18) + oldRewards
let durationMultiplier = if (stakeDuration > 30 days) {
1e18 + 1e17 // 1.1x for 30+ days
} else if (stakeDuration > 7 days) {
1e18 + 5e16 // 1.05x for 7+ days
} else {
1e18
}

return earnedRewards
let baseEarnedRewards = ((staked * (currentRewardPerToken - rewardPerTokenPaid)) / 1e18) + oldRewards

return (baseEarnedRewards * poolPerformanceMultiplier * durationMultiplier) / (1e18 * 1e18)
}

fn updateStakerReward(account: StakingAccount) -> () {
Expand All @@ -60,7 +74,8 @@ Contract Staking(
return rewardPerTokenStored
}

return rewardPerTokenStored + ((blockTimeStamp!() - lastUpdateTime) * rewardRate * 1e18) / totalAmountStaked
let baseReward = ((blockTimeStamp!() - lastUpdateTime) * rewardRate * 1e18) / totalAmountStaked
return rewardPerTokenStored + baseReward
}

@using(assetsInContract = true)
Expand Down Expand Up @@ -137,6 +152,22 @@ Contract Staking(
emit ClaimedRewards(staker, amount)
}

@using(updateFields = true, checkExternalCaller = false)
pub fn compoundRewards() -> () {
let staker = callerAddress!()
let stakingAccount = getStakingAccount(staker)

// Calculate and update rewards
updateStakerReward(stakingAccount)
let rewardsToCompound = stakingAccount.getRewards()
assert!(rewardsToCompound > 0, ErrorCodes.NoRewardsToCompound)

// Convert rewards to staking tokens (through reward token)
stakingAccount.convertAndStakeRewards{selfAddress!() -> rewardsTokenId: rewardsToCompound}()

emit RewardsCompounded(staker, rewardsToCompound)
}

////////////////////////
// Admin Functions
////////////////////////
Expand All @@ -155,4 +186,14 @@ Contract Staking(

migrate!(newBytecode)
}

@using(updateFields = true)
pub fn updatePoolPerformance(newMultiplier: U256) -> () {
onlyOwner(callerAddress!())
assert!(newMultiplier > 0, ErrorCodes.InvalidMultiplier)

let oldMultiplier = poolPerformanceMultiplier
poolPerformanceMultiplier = newMultiplier
emit PoolPerformanceUpdated(oldMultiplier, newMultiplier)
}
}
35 changes: 34 additions & 1 deletion liquid-staking/contracts/staking_account.ral
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Contract StakingAccount(
parentContractAddress: Address,
mut amountStaked: U256,
mut rewardPerTokenPaid: U256,
mut rewards: U256
mut rewards: U256,
mut stakingStartTime: U256
) {
enum StakingAccountErrorCodes {
UnauthorizedAccess = 20
Expand Down Expand Up @@ -36,6 +37,10 @@ Contract StakingAccount(
return rewards
}

pub fn getStakingStartTime() -> U256 {
return stakingStartTime
}

@using(updateFields = true)
pub fn setRewards(newRewards: U256, newRewardPerToken: U256) -> () {
let caller = callerAddress!()
Expand All @@ -51,6 +56,11 @@ Contract StakingAccount(
let caller = callerAddress!()

checkCaller!(caller == parentContractAddress, StakingAccountErrorCodes.UnauthorizedAccess)

if (amountStaked == 0) {
stakingStartTime = blockTimeStamp!()
}

transferTokenToSelf!(staker, tokenId, amount)

amountStaked = amountStaked + amount
Expand Down Expand Up @@ -106,4 +116,27 @@ Contract StakingAccount(

return amount
}

@using(preapprovedAssets = true, updateFields = true)
pub fn convertAndStakeRewards(rewardsAmount: U256) -> () {
let caller = callerAddress!()
checkCaller!(caller == parentContractAddress, StakingAccountErrorCodes.UnauthorizedAccess)

// Convert rewards to staking tokens
let convertedAmount = convertRewardsToStakingTokens(rewardsAmount)

// Auto-stake the converted amount
if (amountStaked == 0) {
stakingStartTime = blockTimeStamp!()
}

amountStaked = amountStaked + convertedAmount
rewards = 0
}

fn convertRewardsToStakingTokens(amount: U256) -> U256 {
// Implement conversion logic based on your tokenomics
// This is a simplified 1:1 conversion
return amount
}
}
157 changes: 148 additions & 9 deletions liquid-staking/test/staking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
addressFromContractId,
groupOfAddress,
sleep,
subContractId
subContractId,
web3
} from '@alephium/web3'
import { Staking, StakingInstance, StakingAccount } from '../artifacts/ts'
import {
Expand All @@ -23,12 +24,21 @@ import {
checkStakingAccount,
transferAlphTo,
alph,
updateStartTime
updateStartTime,
calculateExpectedReward
} from './utils'
import { PrivateKeyWallet } from '@alephium/web3-wallet'
import { testAddress } from '@alephium/web3-test'
import * as base58 from 'bs58'

const DAY = 86400 // seconds in a day
const ONE_TOKEN = 10n ** 18n

// Helper function to advance blockchain time
async function advanceTime(seconds: number) {
await web3.getCurrentNodeProvider().debug.advanceBlockTimeStamp(seconds)
}

describe('test staking', () => {
const groupIndex = groupOfAddress(testAddress)
const stakingDuration = 10 * 1000 // 10s
Expand Down Expand Up @@ -76,12 +86,12 @@ describe('test staking', () => {

test('stake:failed scenarios', async () => {
const [staker] = stakers

// Test staking before pool starts
await stakeFailed(
staker,
staking,
100n,
staker,
staking,
100n,
tokenId,
Number(Staking.consts.ErrorCodes.PoolNotStarted)
)
Expand Down Expand Up @@ -127,9 +137,9 @@ describe('test staking', () => {
test('unstake:after duration', async () => {
const [staker] = stakers
const stakeAmount = 100n

await updateStartTime(staking, ownerWallet)

await stake(staker, staking, stakeAmount, tokenId)
await sleep(stakingDuration + 1000)
await unstake(staker, staking, stakeAmount)
Expand All @@ -141,7 +151,7 @@ describe('test staking', () => {
test('rewards:accumulation and claiming', async () => {
const [staker] = stakers
const stakeAmount = 100n

await updateStartTime(staking, ownerWallet)

await stake(staker, staking, stakeAmount, tokenId)
Expand All @@ -167,3 +177,132 @@ describe('test staking', () => {
expect(state.fields.rewardRate).toEqual(newRate)
})
})

describe('Staking with Compound Rewards', () => {
let staking: StakingInstance
let staker: PrivateKeyWallet
let staker1: PrivateKeyWallet
let staker2: PrivateKeyWallet
let owner: Address
let tokenId: string
let rewardsTokenId: string

beforeEach(async () => {
const groupIndex = groupOfAddress(testAddress)
const startTime = Date.now() * 2000
const ownerWallet = PrivateKeyWallet.Random(groupIndex)
owner = ownerWallet.address

// Deploy contracts
staking = (await deployStaking(
owner,
startTime,
30 * DAY, // 30 days staking duration
10n, // 10% penalty
100n // base reward rate
)).contractInstance

// Setup stakers
staker = PrivateKeyWallet.Random(groupIndex)
staker1 = PrivateKeyWallet.Random(groupIndex)
staker2 = PrivateKeyWallet.Random(groupIndex)

// Initialize tokens
const stakingState = await staking.fetchState()
tokenId = stakingState.fields.tokenId
rewardsTokenId = stakingState.fields.rewardsTokenId

// Fund stakers
for (const s of [staker, staker1, staker2]) {
await transferAlphTo(s.address, alph(1000))
await transferTokenTo(s.address, tokenId, 1000n * ONE_TOKEN)
}

// Start the pool
await updateStartTime(staking, ownerWallet)
})

it('should compound rewards correctly', async () => {
const initialStake = 100n * ONE_TOKEN
await stake(staker, staking, initialStake, '30')

// Advance time to accumulate rewards
await advanceTime(30 * DAY)
// Get initial balances
const beforeStake = await staking.methods.getStakingAccount(staker.address)
await staking.methods.compoundRewards()
const afterStake = await staking.methods.getStakingAccount(staker.address)

expect(afterStake.amountStaked).toBeGreaterThan(beforeStake.amountStaked)
expect(afterStake.rewards).toBe(0n)
})

it('should handle multiple compound operations', async () => {
const initialStake = 100n * ONE_TOKEN
await stake(staker, staking, initialStake, '30')

for (let i = 0; i < 3; i++) {
await advanceTime(7 * DAY)
await staking.methods.compoundRewards()
}

const finalStake = await staking.methods.getStakingAccount(staker.address)
expect(finalStake.amountStaked).toBeGreaterThan(initialStake)
})

describe('Edge Cases', () => {
it('should handle zero staking amount', async () => {
await expect(stake(staker, staking, 0n)).rejects.toThrow()
})

it('should handle maximum staking amount', async () => {
const maxAmount = 2n ** 256n - 1n
await expect(stake(staker, staking, maxAmount)).rejects.toThrow()
})

it('should handle multiple stakers with different durations', async () => {
// Staker 1: 30 days
await stake(staker1, staking, 100n * ONE_TOKEN)
await advanceTime(30 * DAY)

// Staker 2: 7 days
await stake(staker2, staking, 100n * ONE_TOKEN)
await advanceTime(7 * DAY)

const reward1 = await staking.methods.earned(staker1.address)
const reward2 = await staking.methods.earned(staker2.address)

// Staker 1 should have higher rewards due to longer duration
expect(reward1).toBeGreaterThan(reward2)
})
})

describe('Pool Performance', () => {
it('should apply performance multiplier correctly', async () => {
await stake(staker, staking, 100n * ONE_TOKEN)

// Set multiplier to 1.5x
await staking.methods.updatePoolPerformance(1_500_000_000_000_000_000n)
await advanceTime(DAY)

const baseReward = await calculateExpectedReward(100n * ONE_TOKEN, DAY)
const actualReward = await staking.methods.earned(staker.address)

expect(actualReward).toBe(baseReward * 15n / 10n)
})

it('should handle performance multiplier changes', async () => {
await stake(staker, staking, 100n * ONE_TOKEN)

// Change multiplier multiple times
const multipliers = [1.2, 1.5, 0.8].map(m => BigInt(m * 1e18))
for (const multiplier of multipliers) {
await staking.methods.updatePoolPerformance(multiplier)
await advanceTime(DAY)
}

const rewards = await staking.methods.earned(staker.address)
expect(rewards).toBeGreaterThan(0n)
})
})
})
Loading

0 comments on commit f6dfe0c

Please sign in to comment.