diff --git a/test/suites/dev-tanssi-relay/inflation-rewards/test-rewards.ts b/test/suites/dev-tanssi-relay/inflation-rewards/test-rewards.ts index b15d7e4be..f73859ad5 100644 --- a/test/suites/dev-tanssi-relay/inflation-rewards/test-rewards.ts +++ b/test/suites/dev-tanssi-relay/inflation-rewards/test-rewards.ts @@ -1,11 +1,11 @@ import "@tanssi/api-augment"; import { describeSuite, expect, beforeAll, DevModeContext } from "@moonwall/cli"; import { ApiPromise } from "@polkadot/api"; -import { Header, ParaId, HeadData, Digest, DigestItem } from "@polkadot/types/interfaces"; +import { Header, ParaId, HeadData, Digest, DigestItem, Slot } from "@polkadot/types/interfaces"; import { KeyringPair } from "@moonwall/util"; import { fetchIssuance, filterRewardFromContainer, jumpToSession } from "util/block"; import { DANCELIGHT_BOND } from "util/constants"; -import { numberToHex, stringToHex } from "@polkadot/util"; +import { stringToHex } from "@polkadot/util"; //5EYCAe5cHUC3LZehbwavqEb95LcNnpBzfQTsAxeUibSo1Gtb // Helper function to make rewards work for a specific block and slot. @@ -25,13 +25,14 @@ async function mockAndInsertHeadData( const relayApi = context.polkadotJs(); const aura_engine_id = stringToHex("aura"); - const digestItem: DigestItem = await relayApi.createType("DigestItem", { - PreRuntime: [aura_engine_id, numberToHex(slotNumber, 64)], + const slotNumberT: Slot = relayApi.createType("Slot", slotNumber); + const digestItem: DigestItem = relayApi.createType("DigestItem", { + PreRuntime: [aura_engine_id, slotNumberT.toHex(true)], }); - const digest: Digest = await relayApi.createType("Digest", { + const digest: Digest = relayApi.createType("Digest", { logs: [digestItem], }); - const header: Header = await relayApi.createType("Header", { + const header: Header = relayApi.createType("Header", { parentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", number: blockNumber, stateRoot: "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -39,8 +40,8 @@ async function mockAndInsertHeadData( digest, }); - const headData: HeadData = await relayApi.createType("HeadData", header.toHex()); - const paraHeadKey = await relayApi.query.paras.heads.key(paraId); + const headData: HeadData = relayApi.createType("HeadData", header.toHex()); + const paraHeadKey = relayApi.query.paras.heads.key(paraId); await context.createBlock( relayApi.tx.sudo @@ -116,12 +117,11 @@ describeSuite({ // The first account of container 2000 will be rewarded. const accountToReward: string = assignment.containerChains[2000][0]; - const { block } = await context.createBlock(); const accountBalanceBefore = ( await polkadotJs.query.system.account(accountToReward) ).data.free.toBigInt(); - await mockAndInsertHeadData(context, 2000, block.duration, 2, alice); + await mockAndInsertHeadData(context, 2000, 2, 2, alice); await context.createBlock(); const currentChainRewards = (await polkadotJs.query.inflationRewards.chainsToReward()).unwrap(); diff --git a/test/suites/dev-tanssi-relay/staking/test_staking_join.ts b/test/suites/dev-tanssi-relay/staking/test_staking_join.ts new file mode 100644 index 000000000..ec7abcbce --- /dev/null +++ b/test/suites/dev-tanssi-relay/staking/test_staking_join.ts @@ -0,0 +1,110 @@ +import "@tanssi/api-augment"; +import { describeSuite, beforeAll, expect } from "@moonwall/cli"; +import { KeyringPair } from "@moonwall/util"; +import { ApiPromise } from "@polkadot/api"; +import { numberToHex } from "@polkadot/util"; +import { jumpToBlock } from "../../../util/block"; + +describeSuite({ + id: "DT0301", + title: "Fee test suite", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + // TODO: don't hardcode the period here + const sessionPeriod = 10; + + beforeAll(async () => { + alice = context.keyring.alice; + bob = context.keyring.bob; + polkadotJs = context.polkadotJs(); + + // Add alice and box keys to pallet session. In dancebox they are already there in genesis. + const newKey1 = await polkadotJs.rpc.author.rotateKeys(); + const newKey2 = await polkadotJs.rpc.author.rotateKeys(); + + await context.createBlock([ + await polkadotJs.tx.session.setKeys(newKey1, []).signAsync(alice), + await polkadotJs.tx.session.setKeys(newKey2, []).signAsync(bob), + ]); + }); + + it({ + id: "E01", + title: "Cannot execute stake join before 2 sessions", + test: async function () { + const initialSession = 0; + const tx = polkadotJs.tx.pooledStaking.requestDelegate( + alice.address, + "AutoCompounding", + 10000000000000000n + ); + await context.createBlock([await tx.signAsync(alice)]); + const events = await polkadotJs.query.system.events(); + const ev1 = events.filter((a) => { + return a.event.method == "IncreasedStake"; + }); + expect(ev1.length).to.be.equal(1); + const ev2 = events.filter((a) => { + return a.event.method == "UpdatedCandidatePosition"; + }); + expect(ev2.length).to.be.equal(1); + const ev3 = events.filter((a) => { + return a.event.method == "RequestedDelegate"; + }); + expect(ev3.length).to.be.equal(1); + + const stakingCandidates = await polkadotJs.query.pooledStaking.sortedEligibleCandidates(); + expect(stakingCandidates.toJSON()).to.deep.equal([ + { + candidate: alice.address, + stake: numberToHex(10000000000000000, 128), + }, + ]); + + // Ensure that executePendingOperations can only be executed after 2 sessions, meaning that if the + // current session number is 0, we must wait until after the NewSession event for session 2. + // Jump to block 9 + await jumpToBlock(context, 2 * sessionPeriod - 1); + const tx2 = polkadotJs.tx.pooledStaking.executePendingOperations([ + { + delegator: alice.address, + operation: { + JoiningAutoCompounding: { + candidate: alice.address, + at: initialSession, + }, + }, + }, + ]); + + await context.createBlock([await tx2.signAsync(bob)]); + // executePendingOperations failed + const events2 = await polkadotJs.query.system.events(); + const ev4 = events2.filter((a) => { + return a.event.method == "ExtrinsicFailed"; + }); + expect(ev4.length).to.be.equal(1); + + // We are now in block 19, jump to block 21 + await context.createBlock(); + await context.createBlock(); + + // Now the executePendingOperations should succeed + await context.createBlock([await tx2.signAsync(bob)]); + + const events3 = await polkadotJs.query.system.events(); + const ev5 = events3.filter((a) => { + return a.event.method == "StakedAutoCompounding"; + }); + expect(ev5.length).to.be.equal(1); + const ev6 = events3.filter((a) => { + return a.event.method == "ExecutedDelegate"; + }); + expect(ev6.length).to.be.equal(1); + }, + }); + }, +}); diff --git a/test/suites/dev-tanssi-relay/staking/test_staking_rewards_balanced.ts b/test/suites/dev-tanssi-relay/staking/test_staking_rewards_balanced.ts new file mode 100644 index 000000000..480c3abb8 --- /dev/null +++ b/test/suites/dev-tanssi-relay/staking/test_staking_rewards_balanced.ts @@ -0,0 +1,317 @@ +import "@tanssi/api-augment"; +import { describeSuite, expect, beforeAll, DevModeContext } from "@moonwall/cli"; +import { ApiPromise } from "@polkadot/api"; +import { KeyringPair } from "@moonwall/util"; +import { Header, ParaId, HeadData, Digest, DigestItem, Slot } from "@polkadot/types/interfaces"; +import { + fetchIssuance, + fetchRewardAuthorContainers, + filterRewardStakingCollator, + filterRewardStakingDelegators, + jumpSessions, +} from "util/block"; +import { DANCE } from "util/constants"; +import { stringToHex } from "@polkadot/util"; + +export async function createBlockAndRemoveInvulnerables(context: DevModeContext, sudoKey: KeyringPair) { + let nonce = (await context.polkadotJs().rpc.system.accountNextIndex(sudoKey.address)).toNumber(); + const invulnerables = await context.polkadotJs().query.tanssiInvulnerables.invulnerables(); + + const txs = invulnerables.map((invulnerable) => + context + .polkadotJs() + .tx.sudo.sudo(context.polkadotJs().tx.tanssiInvulnerables.removeInvulnerable(invulnerable)) + .signAsync(sudoKey, { nonce: nonce++ }) + ); + + await context.createBlock(txs); +} + +// Helper function to make rewards work for a specific block and slot. +// We need to mock a proper HeadData object for AuthorNoting inherent to work, and thus +// rewards take place. +// +// Basically, if we don't call this function before testing the rewards given +// to collators in a block, the HeadData object mocked in genesis will not be decoded properly +// and the AuthorNoting inherent will fail. +async function mockAndInsertHeadData( + context: DevModeContext, + paraId: ParaId, + blockNumber: number, + slotNumber: number, + sudoAccount: KeyringPair +) { + const relayApi = context.polkadotJs(); + const aura_engine_id = stringToHex("aura"); + + const slotNumberT: Slot = relayApi.createType("Slot", slotNumber); + const digestItem: DigestItem = relayApi.createType("DigestItem", { + PreRuntime: [aura_engine_id, slotNumberT.toHex(true)], + }); + const digest: Digest = relayApi.createType("Digest", { + logs: [digestItem], + }); + const header: Header = relayApi.createType("Header", { + parentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + number: blockNumber, + stateRoot: "0x0000000000000000000000000000000000000000000000000000000000000000", + extrinsicsRoot: "0x0000000000000000000000000000000000000000000000000000000000000000", + digest, + }); + + const headData: HeadData = relayApi.createType("HeadData", header.toHex()); + const paraHeadKey = relayApi.query.paras.heads.key(paraId); + + await context.createBlock( + relayApi.tx.sudo + .sudo(relayApi.tx.system.setStorage([[paraHeadKey, `0xc101${headData.toHex().slice(2)}`]])) + .signAsync(sudoAccount), + { allowFailures: false } + ); +} + +describeSuite({ + id: "DT0302", + title: "Staking candidate reward test suite", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + let charlie: KeyringPair; + let dave: KeyringPair; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + bob = context.keyring.bob; + charlie = context.keyring.charlie; + dave = context.keyring.dave; + + await createBlockAndRemoveInvulnerables(context, alice); + + // Add keys to pallet session. In dancebox they are already there in genesis. + // We need 4 collators because we have 2 chains with 2 collators per chain. + const newKey1 = await polkadotJs.rpc.author.rotateKeys(); + const newKey2 = await polkadotJs.rpc.author.rotateKeys(); + const newKey3 = await polkadotJs.rpc.author.rotateKeys(); + const newKey4 = await polkadotJs.rpc.author.rotateKeys(); + + await context.createBlock([ + await polkadotJs.tx.session.setKeys(newKey1, []).signAsync(alice), + await polkadotJs.tx.session.setKeys(newKey2, []).signAsync(bob), + await polkadotJs.tx.session.setKeys(newKey3, []).signAsync(charlie), + await polkadotJs.tx.session.setKeys(newKey4, []).signAsync(dave), + ]); + + // We will make each of them self-delegate the min amount, while + // we will make each of them delegate the other with 50% + // Alice autocompounding, Bob will be manual + let aliceNonce = (await polkadotJs.rpc.system.accountNextIndex(alice.address)).toNumber(); + let bobNonce = (await polkadotJs.rpc.system.accountNextIndex(bob.address)).toNumber(); + let charlieNonce = (await polkadotJs.rpc.system.accountNextIndex(charlie.address)).toNumber(); + let daveNonce = (await polkadotJs.rpc.system.accountNextIndex(dave.address)).toNumber(); + + await context.createBlock([ + await polkadotJs.tx.pooledStaking + .requestDelegate(alice.address, "AutoCompounding", 10000n * DANCE) + .signAsync(context.keyring.alice, { nonce: aliceNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(alice.address, "ManualRewards", 10000n * DANCE) + .signAsync(context.keyring.bob, { nonce: bobNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(bob.address, "AutoCompounding", 10000n * DANCE) + .signAsync(context.keyring.alice, { nonce: aliceNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(bob.address, "ManualRewards", 10000n * DANCE) + .signAsync(context.keyring.bob, { nonce: bobNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(charlie.address, "AutoCompounding", 10000n * DANCE) + .signAsync(context.keyring.charlie, { nonce: charlieNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(charlie.address, "ManualRewards", 10000n * DANCE) + .signAsync(context.keyring.dave, { nonce: daveNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(dave.address, "AutoCompounding", 10000n * DANCE) + .signAsync(context.keyring.charlie, { nonce: charlieNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(dave.address, "ManualRewards", 10000n * DANCE) + .signAsync(context.keyring.dave, { nonce: daveNonce++ }), + ]); + // At least 2 sessions for the change to have effect + await jumpSessions(context, 2); + // +2 because in tanssi-relay sessions start 1 block later + await context.createBlock(); + await context.createBlock(); + }); + it({ + id: "E01", + title: "Alice should receive rewards through staking now", + test: async function () { + const assignment = (await polkadotJs.query.tanssiCollatorAssignment.collatorContainerChain()).toJSON(); + + // Find alice in list of collators + let paraId = null; + let slotOffset = 0; + const containerIds = [2000, 2001]; + for (const id of containerIds) { + const index = assignment.containerChains[id].indexOf(alice.address); + if (index !== -1) { + paraId = id; + slotOffset = index; + break; + } + } + + expect(paraId, `Alice not found in list of collators: ${assignment}`).to.not.be.null; + + const accountToReward = alice.address; + // 70% is distributed across all rewards + // But we have 2 container chains, so it should get 1/2 of this + const accountBalanceBefore = ( + await polkadotJs.query.system.account(accountToReward) + ).data.free.toBigInt(); + + await mockAndInsertHeadData(context, paraId, 1, 2 + slotOffset, alice); + await context.createBlock(); + const events = await polkadotJs.query.system.events(); + const issuance = await fetchIssuance(events).amount.toBigInt(); + const chainRewards = (issuance * 7n) / 10n; + const rounding = chainRewards % 3n > 0 ? 1n : 0n; + const expectedContainerReward = chainRewards / 2n - rounding; + const rewards = fetchRewardAuthorContainers(events); + expect(rewards.length).toBe(1); + const reward = rewards[0]; + + expect(reward.accountId.toString(), "Alice was not the rewarded collator").toBe(accountToReward); + + const stakingRewardedCollator = filterRewardStakingCollator(events, reward.accountId.toString()); + const stakingRewardedDelegators = filterRewardStakingDelegators(events, reward.accountId.toString()); + + // How much should the author have gotten? + // For now everything as we did not execute the pending operations + expect(reward.balance.toBigInt()).toBeGreaterThanOrEqual(expectedContainerReward - 1n); + expect(reward.balance.toBigInt()).toBeLessThanOrEqual(expectedContainerReward + 1n); + expect(stakingRewardedCollator.manualRewards).to.equal(reward.balance.toBigInt()); + expect(stakingRewardedCollator.autoCompoundingRewards).to.equal(0n); + expect(stakingRewardedDelegators.manualRewards).to.equal(0n); + expect(stakingRewardedDelegators.autoCompoundingRewards).to.equal(0n); + + const accountBalanceAfter = ( + await polkadotJs.query.system.account(accountToReward) + ).data.free.toBigInt(); + + expect(accountBalanceAfter - accountBalanceBefore).to.equal(reward.balance.toBigInt()); + }, + }); + + it({ + id: "E02", + title: "Alice should receive shared rewards with delegators through staking now", + test: async function () { + await jumpSessions(context, 1); + // All pending operations where in session 0 + await context.createBlock([ + await polkadotJs.tx.pooledStaking + .executePendingOperations([ + { + delegator: alice.address, + operation: { + JoiningAutoCompounding: { + candidate: alice.address, + at: 0, + }, + }, + }, + { + delegator: bob.address, + operation: { + JoiningManualRewards: { + candidate: alice.address, + at: 0, + }, + }, + }, + ]) + .signAsync(context.keyring.alice), + ]); + + const totalBacked = ( + await polkadotJs.query.pooledStaking.pools(alice.address, "CandidateTotalStake") + ).toBigInt(); + const totalManual = ( + await polkadotJs.query.pooledStaking.pools(alice.address, "ManualRewardsSharesTotalStaked") + ).toBigInt(); + const totalManualShareSupply = ( + await polkadotJs.query.pooledStaking.pools(alice.address, "ManualRewardsSharesSupply") + ).toBigInt(); + + const assignment = (await polkadotJs.query.tanssiCollatorAssignment.collatorContainerChain()).toJSON(); + // Find alice in list of collators + let paraId = null; + let slotOffset = 0; + const containerIds = [2000, 2001]; + for (const id of containerIds) { + const index = assignment.containerChains[id].indexOf(alice.address); + if (index !== -1) { + paraId = id; + slotOffset = index; + break; + } + } + + expect(paraId, `Alice not found in list of collators: ${assignment}`).to.not.be.null; + + // We create one more block + await mockAndInsertHeadData(context, paraId, 2, 4 + slotOffset, alice); + await context.createBlock(); + const events = await polkadotJs.query.system.events(); + const rewards = fetchRewardAuthorContainers(events); + expect(rewards.length).toBe(1); + const reward = rewards[0]; + + // 20% collator percentage + const collatorPercentage = (20n * reward.balance.toBigInt()) / 100n; + + // Rounding + const delegatorRewards = reward.balance.toBigInt() - collatorPercentage; + + // First, manual rewards + const delegatorManualRewards = (totalManual * delegatorRewards) / totalBacked; + // Check its + const delegatorManualRewardsPerShare = delegatorManualRewards / totalManualShareSupply; + const realDistributedManualDelegatorRewards = delegatorManualRewardsPerShare * totalManualShareSupply; + + // Second, autocompounding + const delegatorsAutoCompoundRewards = delegatorRewards - realDistributedManualDelegatorRewards; + + const stakingRewardedCollator = filterRewardStakingCollator(events, reward.accountId.toString()); + const stakingRewardedDelegators = filterRewardStakingDelegators(events, reward.accountId.toString()); + + // Test ranges, as we can have rounding errors for Perbill manipulation + expect(stakingRewardedDelegators.manualRewards).toBeGreaterThanOrEqual( + realDistributedManualDelegatorRewards - 1n + ); + expect(stakingRewardedDelegators.manualRewards).toBeLessThanOrEqual( + realDistributedManualDelegatorRewards + 1n + ); + expect(stakingRewardedDelegators.autoCompoundingRewards).toBeGreaterThanOrEqual( + delegatorsAutoCompoundRewards - 1n + ); + expect(stakingRewardedDelegators.autoCompoundingRewards).toBeLessThanOrEqual( + delegatorsAutoCompoundRewards + 1n + ); + + // TODO: test better what goes into auto and what goes into manual for collator + const delegatorDust = + delegatorRewards - realDistributedManualDelegatorRewards - delegatorsAutoCompoundRewards; + expect( + stakingRewardedCollator.manualRewards + stakingRewardedCollator.autoCompoundingRewards + ).toBeGreaterThanOrEqual(collatorPercentage + delegatorDust - 1n); + expect( + stakingRewardedCollator.manualRewards + stakingRewardedCollator.autoCompoundingRewards + ).toBeLessThanOrEqual(collatorPercentage + delegatorDust + 1n); + }, + }); + }, +}); diff --git a/test/suites/dev-tanssi-relay/staking/test_staking_rewards_non_balanced.ts b/test/suites/dev-tanssi-relay/staking/test_staking_rewards_non_balanced.ts new file mode 100644 index 000000000..65f865a72 --- /dev/null +++ b/test/suites/dev-tanssi-relay/staking/test_staking_rewards_non_balanced.ts @@ -0,0 +1,314 @@ +import "@tanssi/api-augment"; +import { describeSuite, expect, beforeAll, DevModeContext } from "@moonwall/cli"; +import { ApiPromise } from "@polkadot/api"; +import { KeyringPair } from "@moonwall/util"; +import { Header, ParaId, HeadData, Digest, DigestItem, Slot } from "@polkadot/types/interfaces"; +import { + fetchIssuance, + fetchRewardAuthorContainers, + filterRewardStakingCollator, + filterRewardStakingDelegators, + jumpSessions, +} from "util/block"; +import { DANCE } from "util/constants"; +import { stringToHex } from "@polkadot/util"; + +export async function createBlockAndRemoveInvulnerables(context: DevModeContext, sudoKey: KeyringPair) { + let nonce = (await context.polkadotJs().rpc.system.accountNextIndex(sudoKey.address)).toNumber(); + const invulnerables = await context.polkadotJs().query.tanssiInvulnerables.invulnerables(); + + const txs = invulnerables.map((invulnerable) => + context + .polkadotJs() + .tx.sudo.sudo(context.polkadotJs().tx.tanssiInvulnerables.removeInvulnerable(invulnerable)) + .signAsync(sudoKey, { nonce: nonce++ }) + ); + + await context.createBlock(txs); +} + +// Helper function to make rewards work for a specific block and slot. +// We need to mock a proper HeadData object for AuthorNoting inherent to work, and thus +// rewards take place. +// +// Basically, if we don't call this function before testing the rewards given +// to collators in a block, the HeadData object mocked in genesis will not be decoded properly +// and the AuthorNoting inherent will fail. +async function mockAndInsertHeadData( + context: DevModeContext, + paraId: ParaId, + blockNumber: number, + slotNumber: number, + sudoAccount: KeyringPair +) { + const relayApi = context.polkadotJs(); + const aura_engine_id = stringToHex("aura"); + + const slotNumberT: Slot = relayApi.createType("Slot", slotNumber); + const digestItem: DigestItem = relayApi.createType("DigestItem", { + PreRuntime: [aura_engine_id, slotNumberT.toHex(true)], + }); + const digest: Digest = relayApi.createType("Digest", { + logs: [digestItem], + }); + const header: Header = relayApi.createType("Header", { + parentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + number: blockNumber, + stateRoot: "0x0000000000000000000000000000000000000000000000000000000000000000", + extrinsicsRoot: "0x0000000000000000000000000000000000000000000000000000000000000000", + digest, + }); + + const headData: HeadData = relayApi.createType("HeadData", header.toHex()); + const paraHeadKey = relayApi.query.paras.heads.key(paraId); + + await context.createBlock( + relayApi.tx.sudo + .sudo(relayApi.tx.system.setStorage([[paraHeadKey, `0xc101${headData.toHex().slice(2)}`]])) + .signAsync(sudoAccount), + { allowFailures: false } + ); +} + +describeSuite({ + id: "DT0303", + title: "Staking candidate reward test suite", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + let charlie: KeyringPair; + let dave: KeyringPair; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + alice = context.keyring.alice; + bob = context.keyring.bob; + charlie = context.keyring.charlie; + dave = context.keyring.dave; + + await createBlockAndRemoveInvulnerables(context, alice); + + // Add keys to pallet session. In dancebox they are already there in genesis. + // We need 4 collators because we have 2 chains with 2 collators per chain. + const newKey1 = await polkadotJs.rpc.author.rotateKeys(); + const newKey2 = await polkadotJs.rpc.author.rotateKeys(); + const newKey3 = await polkadotJs.rpc.author.rotateKeys(); + const newKey4 = await polkadotJs.rpc.author.rotateKeys(); + + await context.createBlock([ + await polkadotJs.tx.session.setKeys(newKey1, []).signAsync(alice), + await polkadotJs.tx.session.setKeys(newKey2, []).signAsync(bob), + await polkadotJs.tx.session.setKeys(newKey3, []).signAsync(charlie), + await polkadotJs.tx.session.setKeys(newKey4, []).signAsync(dave), + ]); + + // We will make each of them self-delegate the min amount, while + // we will make each of them delegate the other with 50% + // Alice autocompounding, Bob will be manual + let aliceNonce = (await polkadotJs.rpc.system.accountNextIndex(alice.address)).toNumber(); + let bobNonce = (await polkadotJs.rpc.system.accountNextIndex(bob.address)).toNumber(); + let charlieNonce = (await polkadotJs.rpc.system.accountNextIndex(charlie.address)).toNumber(); + let daveNonce = (await polkadotJs.rpc.system.accountNextIndex(dave.address)).toNumber(); + + await context.createBlock([ + await polkadotJs.tx.pooledStaking + .requestDelegate(alice.address, "AutoCompounding", 18000n * DANCE) + .signAsync(context.keyring.alice, { nonce: aliceNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(alice.address, "ManualRewards", 2000n * DANCE) + .signAsync(context.keyring.bob, { nonce: bobNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(bob.address, "AutoCompounding", 18000n * DANCE) + .signAsync(context.keyring.alice, { nonce: aliceNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(bob.address, "ManualRewards", 2000n * DANCE) + .signAsync(context.keyring.bob, { nonce: bobNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(charlie.address, "AutoCompounding", 18000n * DANCE) + .signAsync(context.keyring.charlie, { nonce: charlieNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(charlie.address, "ManualRewards", 2000n * DANCE) + .signAsync(context.keyring.dave, { nonce: daveNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(dave.address, "AutoCompounding", 18000n * DANCE) + .signAsync(context.keyring.charlie, { nonce: charlieNonce++ }), + await polkadotJs.tx.pooledStaking + .requestDelegate(dave.address, "ManualRewards", 2000n * DANCE) + .signAsync(context.keyring.dave, { nonce: daveNonce++ }), + ]); + // At least 2 sessions for the change to have effect + await jumpSessions(context, 2); + // +2 because in tanssi-relay sessions start 1 block later + await context.createBlock(); + await context.createBlock(); + }); + it({ + id: "E01", + title: "Alice should receive rewards through staking now", + test: async function () { + const assignment = (await polkadotJs.query.tanssiCollatorAssignment.collatorContainerChain()).toJSON(); + + // Find alice in list of collators + let paraId = null; + let slotOffset = 0; + const containerIds = [2000, 2001]; + for (const id of containerIds) { + const index = assignment.containerChains[id].indexOf(alice.address); + if (index !== -1) { + paraId = id; + slotOffset = index; + break; + } + } + + expect(paraId, `Alice not found in list of collators: ${assignment}`).to.not.be.null; + + const accountToReward = alice.address; + // 70% is distributed across all rewards + // But we have 2 container chains, so it should get 1/2 of this + const accountBalanceBefore = ( + await polkadotJs.query.system.account(accountToReward) + ).data.free.toBigInt(); + + await mockAndInsertHeadData(context, paraId, 1, 2 + slotOffset, alice); + await context.createBlock(); + const events = await polkadotJs.query.system.events(); + const issuance = await fetchIssuance(events).amount.toBigInt(); + const chainRewards = (issuance * 7n) / 10n; + const rounding = chainRewards % 3n > 0 ? 1n : 0n; + const expectedContainerReward = chainRewards / 2n - rounding; + const rewards = fetchRewardAuthorContainers(events); + expect(rewards.length).toBe(1); + const reward = rewards[0]; + const stakingRewardedCollator = filterRewardStakingCollator(events, reward.accountId.toString()); + const stakingRewardedDelegators = filterRewardStakingDelegators(events, reward.accountId.toString()); + + // How much should the author have gotten? + // For now everything as we did not execute the pending operations + expect(reward.balance.toBigInt()).toBeGreaterThanOrEqual(expectedContainerReward - 1n); + expect(reward.balance.toBigInt()).toBeLessThanOrEqual(expectedContainerReward + 1n); + expect(stakingRewardedCollator.manualRewards).to.equal(reward.balance.toBigInt()); + expect(stakingRewardedCollator.autoCompoundingRewards).to.equal(0n); + expect(stakingRewardedDelegators.manualRewards).to.equal(0n); + expect(stakingRewardedDelegators.autoCompoundingRewards).to.equal(0n); + + const accountBalanceAfter = ( + await polkadotJs.query.system.account(accountToReward) + ).data.free.toBigInt(); + + expect(accountBalanceAfter - accountBalanceBefore).to.equal(reward.balance.toBigInt()); + }, + }); + + it({ + id: "E02", + title: "Alice should receive shared rewards with delegators through staking now", + test: async function () { + await jumpSessions(context, 1); + // All pending operations where in session 0 + await context.createBlock([ + await polkadotJs.tx.pooledStaking + .executePendingOperations([ + { + delegator: alice.address, + operation: { + JoiningAutoCompounding: { + candidate: alice.address, + at: 0, + }, + }, + }, + { + delegator: bob.address, + operation: { + JoiningManualRewards: { + candidate: alice.address, + at: 0, + }, + }, + }, + ]) + .signAsync(context.keyring.alice), + ]); + + const totalBacked = ( + await polkadotJs.query.pooledStaking.pools(alice.address, "CandidateTotalStake") + ).toBigInt(); + const totalManual = ( + await polkadotJs.query.pooledStaking.pools(alice.address, "ManualRewardsSharesTotalStaked") + ).toBigInt(); + const totalManualShareSupply = ( + await polkadotJs.query.pooledStaking.pools(alice.address, "ManualRewardsSharesSupply") + ).toBigInt(); + + const assignment = (await polkadotJs.query.tanssiCollatorAssignment.collatorContainerChain()).toJSON(); + // Find alice in list of collators + let paraId = null; + let slotOffset = 0; + const containerIds = [2000, 2001]; + for (const id of containerIds) { + const index = assignment.containerChains[id].indexOf(alice.address); + if (index !== -1) { + paraId = id; + slotOffset = index; + break; + } + } + + expect(paraId, `Alice not found in list of collators: ${assignment}`).to.not.be.null; + + // We create one more block + await mockAndInsertHeadData(context, paraId, 2, 4 + slotOffset, alice); + await context.createBlock(); + const events = await polkadotJs.query.system.events(); + const rewards = fetchRewardAuthorContainers(events); + expect(rewards.length).toBe(1); + const reward = rewards[0]; + + // 20% collator percentage + const collatorPercentage = reward.balance.toBigInt() - (80n * reward.balance.toBigInt()) / 100n; + + // Rounding + const delegatorRewards = reward.balance.toBigInt() - collatorPercentage; + + // First, manual rewards + const delegatorManualRewards = (totalManual * delegatorRewards) / totalBacked; + // Check its + const delegatorManualRewardsPerShare = delegatorManualRewards / totalManualShareSupply; + const realDistributedManualDelegatorRewards = delegatorManualRewardsPerShare * totalManualShareSupply; + + // Second, autocompounding + const delegatorsAutoCompoundRewards = delegatorRewards - realDistributedManualDelegatorRewards; + + const stakingRewardedCollator = filterRewardStakingCollator(events, reward.accountId.toString()); + const stakingRewardedDelegators = filterRewardStakingDelegators(events, reward.accountId.toString()); + + // Test ranges, as we can have rounding errors for Perbill manipulation + expect(stakingRewardedDelegators.manualRewards).toBeGreaterThanOrEqual( + realDistributedManualDelegatorRewards - 1n + ); + expect(stakingRewardedDelegators.manualRewards).toBeLessThanOrEqual( + realDistributedManualDelegatorRewards + 1n + ); + expect(stakingRewardedDelegators.autoCompoundingRewards).toBeGreaterThanOrEqual( + delegatorsAutoCompoundRewards - 1n + ); + expect(stakingRewardedDelegators.autoCompoundingRewards).toBeLessThanOrEqual( + delegatorsAutoCompoundRewards + 1n + ); + + // TODO: test better what goes into auto and what goes into manual for collator + const delegatorDust = + delegatorRewards - realDistributedManualDelegatorRewards - delegatorsAutoCompoundRewards; + expect( + stakingRewardedCollator.manualRewards + stakingRewardedCollator.autoCompoundingRewards + ).toBeGreaterThanOrEqual(collatorPercentage + delegatorDust - 1n); + expect( + stakingRewardedCollator.manualRewards + stakingRewardedCollator.autoCompoundingRewards + ).toBeLessThanOrEqual(collatorPercentage + delegatorDust + 1n); + }, + }); + }, +}); diff --git a/test/suites/dev-tanssi-relay/staking/test_staking_session.ts b/test/suites/dev-tanssi-relay/staking/test_staking_session.ts new file mode 100644 index 000000000..40fae3205 --- /dev/null +++ b/test/suites/dev-tanssi-relay/staking/test_staking_session.ts @@ -0,0 +1,99 @@ +import "@tanssi/api-augment"; +import { describeSuite, beforeAll, expect, isExtrinsicSuccessful } from "@moonwall/cli"; +import { KeyringPair, generateKeyringPair } from "@moonwall/util"; +import { ApiPromise } from "@polkadot/api"; +import { numberToHex } from "@polkadot/util"; +import { jumpToBlock } from "../../../util/block"; + +describeSuite({ + id: "DT0304", + title: "Fee test suite", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + // TODO: don't hardcode the period here + const sessionPeriod = 10; + + beforeAll(async () => { + alice = context.keyring.alice; + bob = context.keyring.bob; + polkadotJs = context.polkadotJs(); + + // Add alice and box keys to pallet session. In dancebox they are already there in genesis. + const newKey1 = await polkadotJs.rpc.author.rotateKeys(); + const newKey2 = await polkadotJs.rpc.author.rotateKeys(); + + await context.createBlock([ + await polkadotJs.tx.session.setKeys(newKey1, []).signAsync(alice), + await polkadotJs.tx.session.setKeys(newKey2, []).signAsync(bob), + ]); + }); + + it({ + id: "E01", + title: "It takes 2 sessions to update pallet_session collators", + test: async function () { + const initialCollators = await polkadotJs.query.tanssiCollatorAssignment.collatorContainerChain(); + + const randomAccount = generateKeyringPair("sr25519"); + + const tx = polkadotJs.tx.balances.transferAllowDeath(randomAccount.address, 2n * 10000000000000000n); + await context.createBlock([await tx.signAsync(alice)]); + expect(isExtrinsicSuccessful(await polkadotJs.query.system.events())).to.be.true; + + // Register keys in pallet_session + const newKey = await polkadotJs.rpc.author.rotateKeys(); + const tx2 = polkadotJs.tx.session.setKeys(newKey, []); + await context.createBlock([await tx2.signAsync(randomAccount)]); + expect(isExtrinsicSuccessful(await polkadotJs.query.system.events())).to.be.true; + + // Self-delegate in pallet_pooled_staking + const tx3 = polkadotJs.tx.pooledStaking.requestDelegate( + randomAccount.address, + "AutoCompounding", + 10000000000000000n + ); + await context.createBlock([await tx3.signAsync(randomAccount)]); + const events = await polkadotJs.query.system.events(); + const ev1 = events.filter((a) => { + return a.event.method == "IncreasedStake"; + }); + expect(ev1.length).to.be.equal(1); + const ev2 = events.filter((a) => { + return a.event.method == "UpdatedCandidatePosition"; + }); + expect(ev2.length).to.be.equal(1); + const ev3 = events.filter((a) => { + return a.event.method == "RequestedDelegate"; + }); + expect(ev3.length).to.be.equal(1); + + const stakingCandidates = await polkadotJs.query.pooledStaking.sortedEligibleCandidates(); + expect(stakingCandidates.toJSON()).to.deep.equal([ + { + candidate: randomAccount.address, + stake: numberToHex(10000000000000000, 128), + }, + ]); + + // Jump to block 19 + await jumpToBlock(context, 2 * sessionPeriod); + + // Now pallet_session validators should not include the new one from staking + const collators19 = await polkadotJs.query.tanssiCollatorAssignment.collatorContainerChain(); + expect(collators19.toJSON()).to.deep.equal(initialCollators.toJSON()); + + await context.createBlock(); + // We are now in block 20 but this block cannot include any transactions, so go to 21 + await context.createBlock(); + + // Block 21: candidates that joined pallet_pooled_staking in session 0 are now eligible candidates + const collators21 = await polkadotJs.query.tanssiCollatorAssignment.collatorContainerChain(); + expect(collators21.toJSON().containerChains[2000].length).to.equal(2); + expect(collators21.toJSON().containerChains[2001].length).to.equal(2); + }, + }); + }, +}); diff --git a/test/suites/dev-tanssi-relay/staking/test_staking_swap.ts b/test/suites/dev-tanssi-relay/staking/test_staking_swap.ts new file mode 100644 index 000000000..c0cc58258 --- /dev/null +++ b/test/suites/dev-tanssi-relay/staking/test_staking_swap.ts @@ -0,0 +1,107 @@ +import "@tanssi/api-augment"; +import { describeSuite, beforeAll, expect } from "@moonwall/cli"; +import { KeyringPair } from "@moonwall/util"; +import { ApiPromise } from "@polkadot/api"; +import { numberToHex } from "@polkadot/util"; +import { jumpToBlock } from "../../../util/block"; + +describeSuite({ + id: "DT0305", + title: "Staking poolSwap test suite", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let bob: KeyringPair; + // TODO: don't hardcode the period here + const sessionPeriod = 10; + + beforeAll(async () => { + alice = context.keyring.alice; + bob = context.keyring.bob; + polkadotJs = context.polkadotJs(); + + // Add alice and box keys to pallet session. In dancebox they are already there in genesis. + const newKey1 = await polkadotJs.rpc.author.rotateKeys(); + const newKey2 = await polkadotJs.rpc.author.rotateKeys(); + + await context.createBlock([ + await polkadotJs.tx.session.setKeys(newKey1, []).signAsync(alice), + await polkadotJs.tx.session.setKeys(newKey2, []).signAsync(bob), + ]); + }); + + it({ + id: "E01", + title: "poolSwap works", + test: async function () { + const initialSession = 0; + const tx = polkadotJs.tx.pooledStaking.requestDelegate( + alice.address, + "AutoCompounding", + 10000000000000000n + ); + await context.createBlock([await tx.signAsync(alice)]); + const events = await polkadotJs.query.system.events(); + const ev1 = events.filter((a) => { + return a.event.method == "IncreasedStake"; + }); + expect(ev1.length).to.be.equal(1); + const ev2 = events.filter((a) => { + return a.event.method == "UpdatedCandidatePosition"; + }); + expect(ev2.length).to.be.equal(1); + const ev3 = events.filter((a) => { + return a.event.method == "RequestedDelegate"; + }); + expect(ev3.length).to.be.equal(1); + + const stakingCandidates = await polkadotJs.query.pooledStaking.sortedEligibleCandidates(); + expect(stakingCandidates.toJSON()).to.deep.equal([ + { + candidate: alice.address, + stake: numberToHex(10000000000000000, 128), + }, + ]); + + await jumpToBlock(context, 2 * sessionPeriod + 2); + const tx2 = polkadotJs.tx.pooledStaking.executePendingOperations([ + { + delegator: alice.address, + operation: { + JoiningAutoCompounding: { + candidate: alice.address, + at: initialSession, + }, + }, + }, + ]); + + // Now the executePendingOperations should succeed + await context.createBlock([await tx2.signAsync(bob)]); + + const events3 = await polkadotJs.query.system.events(); + const ev5 = events3.filter((a) => { + return a.event.method == "StakedAutoCompounding"; + }); + expect(ev5.length).to.be.equal(1); + const ev6 = events3.filter((a) => { + return a.event.method == "ExecutedDelegate"; + }); + expect(ev6.length).to.be.equal(1); + + // We now try to swap + const tx3 = polkadotJs.tx.pooledStaking.swapPool(alice.address, "AutoCompounding", { + Stake: 10000000000000000n, + }); + await context.createBlock([await tx3.signAsync(alice)]); + + const events4 = await polkadotJs.query.system.events(); + const ev7 = events4.filter((a) => { + return a.event.method == "SwappedPool"; + }); + expect(ev7.length).to.be.equal(1); + }, + }); + }, +});