feat: staking score 3-state model and noter integration

This commit is contained in:
2026-02-17 01:32:54 +03:00
parent ce35392d67
commit b7d7d008dc
4 changed files with 268 additions and 155 deletions
@@ -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)');
});
});