diff --git a/backend/integration-tests/staking-score.live.test.js b/backend/integration-tests/staking-score.live.test.js index d89f60fd..4066c320 100644 --- a/backend/integration-tests/staking-score.live.test.js +++ b/backend/integration-tests/staking-score.live.test.js @@ -1,11 +1,17 @@ /** * @file: staking-score.live.test.js - * @description: Live integration tests for the StakingScore pallet. - * + * @description: Live integration tests for the StakingScore pallet (v1020007+). + * + * Tests the noter-based staking score system: + * - start_score_tracking() — no stake requirement, user opt-in + * - receive_staking_details() — noter/root submits cached staking data + * - CachedStakingDetails storage — dual-source (RelayChain, AssetHub) + * - Zero stake cleanup + * * @preconditions: - * 1. A local Pezkuwi dev node must be running and accessible at `ws://127.0.0.1:8082`. - * 2. The node must have the `stakingScore` and `staking` pallets. - * 3. Test accounts must be funded to be able to bond stake. + * 1. A local Pezkuwi dev node must be running at WS_ENDPOINT. + * 2. The node must have stakingScore pallet (spec >= 1020007). + * 3. Sudo account (//Alice) must be available for root-origin calls. */ import { ApiPromise, WsProvider, Keyring } from '@pezkuwi/api'; @@ -16,41 +22,31 @@ import { jest } from '@jest/globals'; // TEST CONFIGURATION // ======================================== -const WS_ENDPOINT = 'ws://127.0.0.1:8082'; -jest.setTimeout(120000); // 2 minutes, as this involves waiting for blocks +const WS_ENDPOINT = process.env.WS_ENDPOINT || 'ws://127.0.0.1:8082'; +jest.setTimeout(120000); const UNITS = new BN('1000000000000'); // 10^12 // ======================================== -// TEST SETUP & TEARDOWN +// HELPERS // ======================================== let api; let keyring; -let user1; +let sudo; +let user1; -// Helper to wait for N finalized blocks -const waitForBlocks = async (count) => { - let blocksLeft = count; - return new Promise(resolve => { - const unsubscribe = api.rpc.chain.subscribeFinalizedHeads(() => { - blocksLeft--; - if (blocksLeft <= 0) { - unsubscribe(); - resolve(); - } - }); - }); -}; - -// Helper to send a transaction and wait for it to be finalized const sendAndFinalize = (tx, signer) => { return new Promise((resolve, reject) => { tx.signAndSend(signer, ({ status, dispatchError }) => { if (status.isFinalized) { if (dispatchError) { - const decoded = api.registry.findMetaError(dispatchError.asModule); - reject(new Error(`${decoded.section}.${decoded.name}`)); + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}`)); + } else { + reject(new Error(dispatchError.toString())); + } } else { resolve(); } @@ -59,98 +55,157 @@ const sendAndFinalize = (tx, signer) => { }); }; +const sendSudoAndFinalize = (call) => { + const sudoTx = api.tx.sudo.sudo(call); + return sendAndFinalize(sudoTx, sudo); +}; + beforeAll(async () => { const wsProvider = new WsProvider(WS_ENDPOINT); api = await ApiPromise.create({ provider: wsProvider }); - keyring = new Keyring({ type: 'sr5519' }); - - // Using a fresh account for each test run to avoid state conflicts - user1 = keyring.addFromUri(`//StakingScoreUser${Date.now()}`) + keyring = new Keyring({ type: 'sr25519' }); - // You may need to fund this account using sudo if it has no balance - // For example: - // const sudo = keyring.addFromUri('//Alice'); - // const transferTx = api.tx.balances.transfer(user1.address, UNITS.mul(new BN(10000))); - // await sendAndFinalize(transferTx, sudo); + sudo = keyring.addFromUri('//Alice'); + user1 = keyring.addFromUri(`//StakingScoreUser${Date.now()}`); - console.log('Connected to node and initialized account for StakingScore tests.'); -}, 40000); + // Fund test account + const transferTx = api.tx.balances.transferKeepAlive(user1.address, UNITS.mul(new BN(100))); + await sendAndFinalize(transferTx, sudo); + + console.log('Setup complete. User1:', user1.address); +}, 60000); afterAll(async () => { if (api) await api.disconnect(); }); // ======================================== -// LIVE PALLET TESTS +// TESTS // ======================================== -describe('StakingScore Pallet Live Workflow', () => { +describe('StakingScore Pallet (v1020007+)', () => { - it('should calculate the base score correctly based on staked amount only', async () => { - console.log('Testing base score calculation...'); + it('start_score_tracking() succeeds without any stake', async () => { + // User1 has no staking ledger — should still succeed (no NoStakeFound error) + const tx = api.tx.stakingScore.startScoreTracking(); + await sendAndFinalize(tx, user1); - // Stake 500 PEZ (should result in a base score of 40) - const stakeAmount = UNITS.mul(new BN(500)); - const bondTx = api.tx.staking.bond(stakeAmount, 'Staked'); // Bond to self - await sendAndFinalize(bondTx, user1); - - // Without starting tracking, score should be based on amount only - const { score: scoreBeforeTracking } = await api.query.stakingScore.getStakingScore(user1.address); - expect(scoreBeforeTracking.toNumber()).toBe(40); - console.log(`Verified base score for ${stakeAmount} stake is ${scoreBeforeTracking.toNumber()}.`); - - // Even after waiting, score should not change - await waitForBlocks(5); - const { score: scoreAfterWaiting } = await api.query.stakingScore.getStakingScore(user1.address); - expect(scoreAfterWaiting.toNumber()).toBe(40); - console.log('Verified score does not change without tracking enabled.'); + // Verify StakingStartBlock is set + const startBlock = await api.query.stakingScore.stakingStartBlock(user1.address); + expect(startBlock.isSome).toBe(true); + console.log('start_score_tracking succeeded, startBlock:', startBlock.unwrap().toNumber()); }); - it('should apply duration multiplier after tracking is started', async () => { - console.log('Testing duration multiplier...'); - const MONTH_IN_BLOCKS = api.consts.stakingScore.monthInBlocks.toNumber(); - - // User1 already has 500 PEZ staked from the previous test. - // Now, let's start tracking. - const startTrackingTx = api.tx.stakingScore.startScoreTracking(); - await sendAndFinalize(startTrackingTx, user1); - console.log('Score tracking started for User1.'); - - // Wait for 4 months - console.log(`Waiting for 4 months (${4 * MONTH_IN_BLOCKS} blocks)...`); - await waitForBlocks(4 * MONTH_IN_BLOCKS); - - // Score should now be 40 (base) * 1.5 (4 month multiplier) = 60 - const { score: scoreAfter4Months } = await api.query.stakingScore.getStakingScore(user1.address); - expect(scoreAfter4Months.toNumber()).toBe(60); - console.log(`Verified score after 4 months is ${scoreAfter4Months.toNumber()}.`); - - // Wait for another 9 months (total 13 months) to reach max multiplier - console.log(`Waiting for another 9 months (${9 * MONTH_IN_BLOCKS} blocks)...`); - await waitForBlocks(9 * MONTH_IN_BLOCKS); - - // Score should be 40 (base) * 2.0 (12+ month multiplier) = 80 - const { score: scoreAfter13Months } = await api.query.stakingScore.getStakingScore(user1.address); - expect(scoreAfter13Months.toNumber()).toBe(80); - console.log(`Verified score after 13 months is ${scoreAfter13Months.toNumber()}.`); + it('start_score_tracking() fails with TrackingAlreadyStarted', async () => { + const tx = api.tx.stakingScore.startScoreTracking(); + await expect(sendAndFinalize(tx, user1)) + .rejects.toThrow('stakingScore.TrackingAlreadyStarted'); }); - it('should fail to start tracking if no stake is found or already tracking', async () => { - const freshUser = keyring.addFromUri(`//FreshUser${Date.now()}`); - // You would need to fund this freshUser account for it to pay transaction fees. + it('receive_staking_details() fails without noter authority', async () => { + // User1 is not a noter — should fail with NotAuthorized + const tx = api.tx.stakingScore.receiveStakingDetails( + user1.address, + 'RelayChain', + UNITS.mul(new BN(500)).toString(), // 500 HEZ + 3, // nominations_count + 0, // unlocking_chunks_count + ); + await expect(sendAndFinalize(tx, user1)) + .rejects.toThrow('stakingScore.NotAuthorized'); + }); - console.log('Testing failure cases for start_score_tracking...'); + it('receive_staking_details() succeeds via sudo (root origin)', async () => { + const call = api.tx.stakingScore.receiveStakingDetails( + user1.address, + 'RelayChain', + UNITS.mul(new BN(500)).toString(), // 500 HEZ + 3, // nominations_count + 1, // unlocking_chunks_count + ); + await sendSudoAndFinalize(call); - // Case 1: No stake found - await expect( - sendAndFinalize(api.tx.stakingScore.startScoreTracking(), freshUser) - ).rejects.toThrow('stakingScore.NoStakeFound'); - console.log('Verified: Cannot start tracking without a stake.'); + // Verify CachedStakingDetails is set + const cached = await api.query.stakingScore.cachedStakingDetails( + user1.address, 'RelayChain' + ); + expect(cached.isSome).toBe(true); - // Case 2: Already tracking (using user1 from previous tests) - await expect( - sendAndFinalize(api.tx.stakingScore.startScoreTracking(), user1) - ).rejects.toThrow('stakingScore.TrackingAlreadyStarted'); - console.log('Verified: Cannot start tracking when already started.'); + const details = cached.unwrap().toJSON(); + const stakedAmount = BigInt(details.stakedAmount ?? details.staked_amount ?? '0'); + expect(stakedAmount).toBe(UNITS.mul(new BN(500)).toBigInt()); + expect(details.nominationsCount ?? details.nominations_count).toBe(3); + expect(details.unlockingChunksCount ?? details.unlocking_chunks_count).toBe(1); + + console.log('CachedStakingDetails verified:', details); + }); + + it('receive_staking_details() supports dual-source (AssetHub)', async () => { + const call = api.tx.stakingScore.receiveStakingDetails( + user1.address, + 'AssetHub', + UNITS.mul(new BN(200)).toString(), // 200 HEZ from Asset Hub pool + 0, + 0, + ); + await sendSudoAndFinalize(call); + + // Both sources should exist + const relay = await api.query.stakingScore.cachedStakingDetails(user1.address, 'RelayChain'); + const assetHub = await api.query.stakingScore.cachedStakingDetails(user1.address, 'AssetHub'); + expect(relay.isSome).toBe(true); + expect(assetHub.isSome).toBe(true); + + console.log('Dual-source staking data verified'); + }); + + it('zero stake cleans up CachedStakingDetails for a source', async () => { + // Send zero stake for AssetHub — should remove that entry + const call = api.tx.stakingScore.receiveStakingDetails( + user1.address, + 'AssetHub', + '0', // zero stake + 0, + 0, + ); + await sendSudoAndFinalize(call); + + // AssetHub entry should be gone + const assetHub = await api.query.stakingScore.cachedStakingDetails(user1.address, 'AssetHub'); + expect(assetHub.isNone || assetHub.isEmpty).toBe(true); + + // RelayChain entry should still exist + const relay = await api.query.stakingScore.cachedStakingDetails(user1.address, 'RelayChain'); + expect(relay.isSome).toBe(true); + + // StakingStartBlock should still exist (still has relay stake) + const startBlock = await api.query.stakingScore.stakingStartBlock(user1.address); + expect(startBlock.isSome).toBe(true); + + console.log('Zero stake cleanup verified (AssetHub removed, RelayChain kept)'); + }); + + it('zero stake on all sources removes StakingStartBlock', async () => { + // Remove relay chain stake too + const call = api.tx.stakingScore.receiveStakingDetails( + user1.address, + 'RelayChain', + '0', + 0, + 0, + ); + await sendSudoAndFinalize(call); + + // Both sources should be empty + const relay = await api.query.stakingScore.cachedStakingDetails(user1.address, 'RelayChain'); + const assetHub = await api.query.stakingScore.cachedStakingDetails(user1.address, 'AssetHub'); + expect(relay.isNone || relay.isEmpty).toBe(true); + expect(assetHub.isNone || assetHub.isEmpty).toBe(true); + + // StakingStartBlock should also be cleaned up + const startBlock = await api.query.stakingScore.stakingStartBlock(user1.address); + expect(startBlock.isNone || startBlock.isEmpty).toBe(true); + + console.log('Full cleanup verified (all sources + StakingStartBlock removed)'); }); }); diff --git a/shared/lib/scores.ts b/shared/lib/scores.ts index a6cf5c59..27b1ec43 100644 --- a/shared/lib/scores.ts +++ b/shared/lib/scores.ts @@ -25,6 +25,7 @@ export interface UserScores { export interface StakingScoreStatus { isTracking: boolean; + hasCachedData: boolean; // Whether noter has submitted staking data startBlock: number | null; currentBlock: number; durationBlocks: number; @@ -188,28 +189,46 @@ export async function getStakingScoreStatus( ): Promise { try { if (!peopleApi?.query?.stakingScore?.stakingStartBlock) { - return { isTracking: false, startBlock: null, currentBlock: 0, durationBlocks: 0 }; + return { isTracking: false, hasCachedData: false, startBlock: null, currentBlock: 0, durationBlocks: 0 }; } const startBlockResult = await peopleApi.query.stakingScore.stakingStartBlock(address); const currentBlock = Number((await peopleApi.query.system.number()).toString()); if (startBlockResult.isEmpty || startBlockResult.isNone) { - return { isTracking: false, startBlock: null, currentBlock, durationBlocks: 0 }; + return { isTracking: false, hasCachedData: false, startBlock: null, currentBlock, durationBlocks: 0 }; } const startBlock = Number(startBlockResult.toString()); const durationBlocks = currentBlock - startBlock; + // Check if noter has submitted cached staking data + let hasCachedData = false; + if (peopleApi.query.stakingScore.cachedStakingDetails) { + try { + const [relayResult, assetHubResult] = await Promise.all([ + peopleApi.query.stakingScore.cachedStakingDetails(address, 'RelayChain') + .catch(() => ({ isSome: false, isEmpty: true })), + peopleApi.query.stakingScore.cachedStakingDetails(address, 'AssetHub') + .catch(() => ({ isSome: false, isEmpty: true })), + ]); + hasCachedData = (relayResult.isSome || !relayResult.isEmpty) || + (assetHubResult.isSome || !assetHubResult.isEmpty); + } catch { + hasCachedData = false; + } + } + return { isTracking: true, + hasCachedData, startBlock, currentBlock, durationBlocks }; } catch (error) { console.error('Error fetching staking score status:', error); - return { isTracking: false, startBlock: null, currentBlock: 0, durationBlocks: 0 }; + return { isTracking: false, hasCachedData: false, startBlock: null, currentBlock: 0, durationBlocks: 0 }; } } @@ -217,8 +236,8 @@ export async function getStakingScoreStatus( * Start staking score tracking * Calls: stakingScore.startScoreTracking() * - * Called on People Chain. Requires staking data to be available via - * cachedStakingDetails (pushed from Asset Hub via XCM). + * Called on People Chain. No stake requirement - user opts in, then a + * noter-authorized account submits staking data via receive_staking_details(). */ export async function startScoreTracking( peopleApi: ApiPromise, diff --git a/shared/lib/staking.ts b/shared/lib/staking.ts index 7d665b64..d2f1993e 100644 --- a/shared/lib/staking.ts +++ b/shared/lib/staking.ts @@ -40,6 +40,7 @@ export interface StakingInfo { stakingScore: number | null; stakingDuration: number | null; // Duration in blocks hasStartedScoreTracking: boolean; + hasCachedStakingData: boolean; // Whether noter has submitted staking data to People Chain isValidator: boolean; pezRewards: PezRewardInfo | null; // PEZ rewards information } @@ -236,9 +237,10 @@ export async function getStakingInfo( let stakingScore: number | null = null; let stakingDuration: number | null = null; let hasStartedScoreTracking = false; + let hasCachedStakingData = false; try { - // stakingScore pallet is on People Chain - uses cached staking data from Asset Hub via XCM + // stakingScore pallet is on People Chain - uses cached staking data submitted by noter const scoreApi = peopleApi || api; if (scoreApi.query.stakingScore && scoreApi.query.stakingScore.stakingStartBlock) { // Check if user has started score tracking @@ -252,47 +254,79 @@ export async function getStakingInfo( const durationInBlocks = currentBlock - startBlock; stakingDuration = durationInBlocks; - // Calculate amount-based score (20-50 points) - const stakedHEZ = ledger ? parseFloat(formatBalance(ledger.total)) : 0; - let amountScore = 20; // Default + // Check if noter has submitted cached staking data to People Chain + // CachedStakingDetails is a DoubleMap: (AccountId, StakingSource) -> StakingDetails + // StakingSource: RelayChain = 0, AssetHub = 1 + // StakingDetails: { staked_amount, nominations_count, unlocking_chunks_count } + let totalCachedStakeWei = BigInt(0); + if (scoreApi.query.stakingScore.cachedStakingDetails) { + try { + const [relayResult, assetHubResult] = await Promise.all([ + scoreApi.query.stakingScore.cachedStakingDetails(address, 'RelayChain') + .catch(() => null), + scoreApi.query.stakingScore.cachedStakingDetails(address, 'AssetHub') + .catch(() => null), + ]); - if (stakedHEZ <= 100) { - amountScore = 20; - } else if (stakedHEZ <= 250) { - amountScore = 30; - } else if (stakedHEZ <= 750) { - amountScore = 40; - } else { - amountScore = 50; // 751+ HEZ + if (relayResult && !relayResult.isEmpty && relayResult.isSome) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (relayResult.unwrap() as any).toJSON() as any; + totalCachedStakeWei += BigInt(json.stakedAmount ?? json.staked_amount ?? '0'); + hasCachedStakingData = true; + } + if (assetHubResult && !assetHubResult.isEmpty && assetHubResult.isSome) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const json = (assetHubResult.unwrap() as any).toJSON() as any; + totalCachedStakeWei += BigInt(json.stakedAmount ?? json.staked_amount ?? '0'); + hasCachedStakingData = true; + } + } catch { + hasCachedStakingData = false; + } } - // Calculate duration multiplier - const MONTH_IN_BLOCKS = 30 * 24 * 60 * 10; // 432,000 blocks (~30 days, 6s per block) - let durationMultiplier = 1.0; + if (hasCachedStakingData) { + // Use cached stake from People Chain (matches on-chain pallet calculation) + const stakedHEZ = Number(totalCachedStakeWei / BigInt(10 ** 12)); + let amountScore = 20; // Default - if (durationInBlocks >= 12 * MONTH_IN_BLOCKS) { - durationMultiplier = 2.0; // 12+ months - } else if (durationInBlocks >= 6 * MONTH_IN_BLOCKS) { - durationMultiplier = 1.7; // 6-11 months - } else if (durationInBlocks >= 3 * MONTH_IN_BLOCKS) { - durationMultiplier = 1.4; // 3-5 months - } else if (durationInBlocks >= MONTH_IN_BLOCKS) { - durationMultiplier = 1.2; // 1-2 months - } else { - durationMultiplier = 1.0; // < 1 month + if (stakedHEZ <= 100) { + amountScore = 20; + } else if (stakedHEZ <= 250) { + amountScore = 30; + } else if (stakedHEZ <= 750) { + amountScore = 40; + } else { + amountScore = 50; // 751+ HEZ + } + + // Calculate duration multiplier + const MONTH_IN_BLOCKS = 30 * 24 * 60 * 10; // 432,000 blocks (~30 days, 6s per block) + let durationMultiplier = 1.0; + + if (durationInBlocks >= 12 * MONTH_IN_BLOCKS) { + durationMultiplier = 2.0; // 12+ months + } else if (durationInBlocks >= 6 * MONTH_IN_BLOCKS) { + durationMultiplier = 1.7; // 6-11 months + } else if (durationInBlocks >= 3 * MONTH_IN_BLOCKS) { + durationMultiplier = 1.4; // 3-5 months + } else if (durationInBlocks >= MONTH_IN_BLOCKS) { + durationMultiplier = 1.2; // 1-2 months + } else { + durationMultiplier = 1.0; // < 1 month + } + + // Final score calculation (max 100) + stakingScore = Math.min(100, Math.floor(amountScore * durationMultiplier)); + + console.log('Staking score calculated:', { + stakedHEZ, + amountScore, + durationInBlocks, + durationMultiplier, + finalScore: stakingScore + }); } - - // Final score calculation (max 100) - // This MUST match the pallet's integer math: amount_score * multiplier_numerator / multiplier_denominator - stakingScore = Math.min(100, Math.floor(amountScore * durationMultiplier)); - - console.log('Staking score calculated:', { - stakedHEZ, - amountScore, - durationInBlocks, - durationMultiplier, - finalScore: stakingScore - }); } } } catch (error) { @@ -315,6 +349,7 @@ export async function getStakingInfo( stakingScore, stakingDuration, hasStartedScoreTracking, + hasCachedStakingData, isValidator, pezRewards }; diff --git a/web/src/components/staking/StakingDashboard.tsx b/web/src/components/staking/StakingDashboard.tsx index c11dd920..a77de839 100644 --- a/web/src/components/staking/StakingDashboard.tsx +++ b/web/src/components/staking/StakingDashboard.tsx @@ -376,11 +376,6 @@ export const StakingDashboard: React.FC = () => { const handleStartScoreTracking = async () => { if (!peopleApi || !selectedAccount) return; - if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) { - toast.error('You must bond tokens before starting score tracking'); - return; - } - setIsLoading(true); try { const injector = await getInjectorSigner(selectedAccount.address); @@ -496,23 +491,32 @@ export const StakingDashboard: React.FC = () => { {stakingInfo?.hasStartedScoreTracking ? ( - <> -
- {stakingInfo.stakingScore}/100 -
-

- Duration: {stakingInfo.stakingDuration - ? `${Math.floor(stakingInfo.stakingDuration / (24 * 60 * 10))} days` - : '0 days'} -

- + stakingInfo.hasCachedStakingData ? ( + <> +
+ {stakingInfo.stakingScore}/100 +
+

+ Duration: {stakingInfo.stakingDuration + ? `${Math.floor(stakingInfo.stakingDuration / (24 * 60 * 10))} days` + : '0 days'} +

+ + ) : ( + <> +
Waiting for data...
+

+ Score tracking started. A noter will submit your staking data soon. +

+ + ) ) : ( <>
Not Started