Skip to content

Distribute Pooled Staking rewards once per session and reduce the amount of events#1469

Open
nanocryk wants to merge 21 commits intomasterfrom
jeremy-pooled-staking-aggregate-rewards-distrubution
Open

Distribute Pooled Staking rewards once per session and reduce the amount of events#1469
nanocryk wants to merge 21 commits intomasterfrom
jeremy-pooled-staking-aggregate-rewards-distrubution

Conversation

@nanocryk
Copy link
Contributor

Accumulates pending rewards in storage to effectively do the distribution later (once per session in dancelight).

@coderabbitai
Copy link

coderabbitai bot commented Jan 19, 2026

📝 Walkthrough

Walkthrough

Aggregate pooled-staking rewards into on‑chain PendingRewards and defer distribution to a timer triggered at session/block boundaries; runtime configs, pallet logic, events, tests, and TypeScript API types/queries updated to support accumulation and timed distribution.

Changes

Cohort / File(s) Summary
Runtime configs
chains/.../runtime/dancebox/src/lib.rs, chains/.../runtime/dancelight/src/lib.rs, chains/.../runtime/starlight/src/lib.rs
Added RewardsDistributionSessionDelay: u32 = 1 and wired type RewardsDistributionTimer = SessionTimer<Runtime, RewardsDistributionSessionDelay> into impl pallet_pooled_staking::Config.
Runtime test harness
chains/orchestrator-relays/runtime/dancelight/src/tests/common/mod.rs, chains/orchestrator-relays/runtime/starlight/src/tests/common/mod.rs, chains/integration-tests/dancebox-runtime-test-utils/src/lib.rs
Re-exported PooledStaking and call PooledStaking::on_initialize(System::block_number()) in test start_block helpers to exercise timer-driven distribution.
Pallet config & storage
pallets/pooled-staking/src/lib.rs, pallets/pooled-staking/src/mock.rs
Added associated type RewardsDistributionTimer: Timer; introduced public PendingRewards storage type; added RewardsDistributed event and deprecations; wired on_initialize hook to perform timed distribution; mock uses BlockNumberTimer for tests.
Pools & distribution logic
pallets/pooled-staking/src/pools.rs
Introduced PendingRewards struct and public APIs: accumulate_rewards, distribute_accumulated_rewards*, distribute_accumulated_rewards_on_timer; changed distribute_rewards signature to take T::Balance; centralized aggregation and single RewardsDistributed event; test helper for immediate distribution.
Candidate events
pallets/pooled-staking/src/candidate.rs
Removed emission of IncreasedStake / DecreasedStake; emit UpdatedCandidatePosition only when position actually changes.
Pallet tests (Rust)
pallets/pooled-staking/src/tests/*.rs
Updated tests to reflect aggregation/timed distribution: conditionalized/removed stake-change event assertions; added setup_delegations helper and rewards_are_aggregated_then_distributed test; validate PendingRewards storage and distribution sequencing.
Runtime tests: inflation_rewards & integration
chains/.../tests/inflation_rewards.rs, chains/integration-tests/.../src/tests/integration_test.rs
Switched from immediate balance assertions to validating PendingRewards.last_distribution and stored pending rewards; advance sessions to observe actual payout.
Integration tests (JS/TS) & utils
test/suites/**/staking/*.ts, test/utils/block.ts
Replaced per-category reward filters with filterRewardStakingDistributed; added fetchRewardStakingDistributed and pendingRewards query usage; advance sessions before asserting distributions; removed some stake-change event checks.
TypeScript API types & queries
typescript-api/src/*/{augment-api-events,augment-api-query,lookup,registry,types-lookup}.ts (dancebox, dancelight, starlight)
Added RewardsDistributed event typings; added pendingRewards storage query and PalletPooledStakingPoolsPendingRewards type (lastDistribution: u32, rewards: BTreeMap<AccountId32, u128>); updated lookup/registry/type maps.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller
    participant Pallet as PooledStaking
    participant Accumulator as PendingRewards (Storage)
    participant Timer as RewardsDistributionTimer / OnInitialize
    participant Currency as Currency

    Caller->>Pallet: accumulate_rewards(candidate, amount)
    Pallet->>Accumulator: add amount to candidate entry
    Pallet-->>Caller: return (no immediate transfer)

    Note over Timer,Accumulator: Timer elapses (session/block boundary)
    Timer->>Accumulator: read pending rewards
    loop per candidate
      Timer->>Pallet: distribute_accumulated_rewards(candidate, amount)
      Pallet->>Currency: transfer(amount -> recipients)
      Currency-->>Pallet: transfer result
      Pallet->>Timer: emit RewardsDistributed(candidate, amounts)
    end
    Timer->>Accumulator: clear distributed entries / update last_distribution
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

A8-mergeoncegreen, R9-allruntimes

Suggested reviewers

  • tmpolaczyk
  • evgeny-s
  • Agusrodri
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: deferring pooled staking reward distribution to once per session and reducing event emissions.
Description check ✅ Passed The description relates directly to the changeset by explaining the accumulation of pending rewards in storage for later distribution once per session.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 19, 2026

WASM runtime size check:

Compared to target branch

dancebox runtime: 1900 KB (no changes) ✅

flashbox runtime: 1124 KB (no changes) ✅

dancelight runtime: 2672 KB (+16 KB) 🚨

starlight runtime: 2584 KB (no changes) 🚨

container chain template simple runtime: 1516 KB (no changes) ✅

container chain template frontier runtime: 1848 KB (no changes) ✅

@nanocryk nanocryk added a0-pleasereview Pull request needs code review. B7-runtimenoteworthy Changes should be noted in any runtime-upgrade release notes D9-needsaudit👮 PR contains changes to fund-managing logic that should be properly reviewed and externally audited R8-orchestratorsolo change affects dancelight and starlight only V-1800 labels Jan 23, 2026
@nanocryk nanocryk marked this pull request as ready for review January 23, 2026 09:05
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/suites/dev-tanssi/staking/test_staking_rewards_non_balanced.ts (1)

172-212: Re-fetch events after the session jump for RewardsDistributed.

With distribution deferred to session boundaries, filtering the pre-jump events can miss the RewardsDistributed event and make the assertions stale.

🔧 Suggested fix
-                const events = await polkadotJs.query.system.events();
-                const reward = await fetchRewardAuthorOrchestrator(events);
-
-                await jumpSessions(context, 1);
+                const events = await polkadotJs.query.system.events();
+                const reward = await fetchRewardAuthorOrchestrator(events);
+
+                await jumpSessions(context, 1);
+                const distributionEvents = await polkadotJs.query.system.events();
@@
-                const stakingRewarded = await filterRewardStakingDistributed(events, reward.accountId.toString());
+                const stakingRewarded = await filterRewardStakingDistributed(
+                    distributionEvents,
+                    reward.accountId.toString()
+                );
🤖 Fix all issues with AI agents
In
`@chains/orchestrator-relays/runtime/dancelight/src/tests/inflation_rewards.rs`:
- Around line 19-23: The import traits::Timer from pallet_pooled_staking is
unused and causing CI to fail; remove traits::Timer from the use list in the
test file (the glob that currently reads pallet_pooled_staking::{ traits::Timer,
ActivePoolKind, PendingOperationKey, PendingOperationQuery }) or alternatively
reference Timer where intended—prefer simply deleting traits::Timer to eliminate
the unused import.

In `@pallets/pooled-staking/src/lib.rs`:
- Around line 590-642: The weight increments in on_initialize are being dropped
because Weight::saturating_add returns a new Weight rather than mutating; update
each discarded call to assign back to weight (e.g., replace
weight.saturating_add(T::DbWeight::get().reads(2)) and the other two calls with
weight = weight.saturating_add(...)) so that the reads/writes and per-reward
fn_weight are correctly accumulated; refer to the on_initialize function, the
local variable weight, and the calls to T::DbWeight::get().reads/writes(...) and
fn_weight to locate the three places to fix.

In `@pallets/pooled-staking/src/pools.rs`:
- Around line 838-843: The PendingRewards struct uses #[pallet::unbounded] on
its BTreeMap<Candidate, Balance>, so the missing MaxEncodedLen is intentional;
update the code by adding a concise comment above the PendingRewards definition
(or the associated storage item) documenting why unbounded storage is
acceptable: mention the #[pallet::unbounded] attribute, that rewards are
accumulated in BTreeMap<Candidate, Balance>, that EligibleCandidatesBufferSize
(100) and the distribution logic clear pending rewards each cycle (so growth is
bounded in practice), and note any assumptions for future maintainers; reference
PendingRewards, BTreeMap, #[pallet::unbounded], and EligibleCandidatesBufferSize
in the comment.

In `@test/suites/dev-tanssi-relay/staking/test_staking_rewards_non_balanced.ts`:
- Around line 177-185: The test accesses pendingRewards.toJSON().rewards without
checking the Option; guard the result of
polkadotJs.query.pooledStaking.pendingRewards() with its isSome/isNone before
unwrapping: call pendingRewards.isSome() and only read
pendingRewards.toJSON().rewards (and derive collatorPendingRewards) when it's
Some, otherwise treat collatorPendingRewards as undefined/null or an empty value
and adjust the assertion against reward (or assert pendingRewards.isSome() if
the test requires rewards to exist). Update references in the test to use the
safe-guarded value for collatorPendingRewards and avoid direct unwrapping of
pendingRewards.
🧹 Nitpick comments (8)
test/suites/dev-tanssi-relay/staking/test_staking_join.ts (1)

81-88: Remove commented-out test assertions instead of leaving dead code.

If IncreasedStake and UpdatedCandidatePosition events are no longer emitted immediately upon requestDelegate due to the deferred distribution model, these assertions should be deleted rather than commented out. Commented-out code reduces test clarity and creates maintenance burden.

If there's a specific reason to keep these (e.g., events are still emitted but at a different point), consider adding a test that verifies the new expected behavior during session-based distribution instead.

♻️ Suggested change
             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) => {
test/suites/dev-tanssi-relay/staking/test_staking_swap.ts (1)

85-92: Remove commented-out event assertions.

Keeping the commented block adds noise now that those events are no longer asserted; consider deleting it.

🧹 Suggested cleanup
-                // 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);
test/suites/dev-tanssi-relay/staking/test_staking_session.ts (1)

84-91: Remove commented-out event assertions.

Keeping the commented block adds noise now that those events are no longer asserted; consider deleting it.

🧹 Suggested cleanup
-                // 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);
pallets/pooled-staking/src/tests/candidates.rs (1)

204-236: Avoid temporary Vec allocation when extending expected events.

extend_from_slice(&vec![...]) allocates a throwaway Vec. You can push each event (or extend from an array) to keep it allocation-free. This applies to the similar blocks below as well.

♻️ Example refactor
-                        expected_events.extend_from_slice(&vec![
-                            Event::RequestedDelegate {
-                                candidate: action.candidate,
-                                delegator: action.delegator,
-                                pool: ActivePoolKind::AutoCompounding,
-                                pending: action.amount,
-                            },
-                            Event::StakedAutoCompounding {
-                                candidate: action.candidate,
-                                delegator: action.delegator,
-                                shares: action.amount / share,
-                                stake: action.amount,
-                            },
-                            Event::ExecutedDelegate {
-                                candidate: action.candidate,
-                                delegator: action.delegator,
-                                pool: ActivePoolKind::AutoCompounding,
-                                staked: action.amount,
-                                released: 0,
-                            },
-                        ]);
+                        expected_events.push(Event::RequestedDelegate {
+                            candidate: action.candidate,
+                            delegator: action.delegator,
+                            pool: ActivePoolKind::AutoCompounding,
+                            pending: action.amount,
+                        });
+                        expected_events.push(Event::StakedAutoCompounding {
+                            candidate: action.candidate,
+                            delegator: action.delegator,
+                            shares: action.amount / share,
+                            stake: action.amount,
+                        });
+                        expected_events.push(Event::ExecutedDelegate {
+                            candidate: action.candidate,
+                            delegator: action.delegator,
+                            pool: ActivePoolKind::AutoCompounding,
+                            staked: action.amount,
+                            released: 0,
+                        });
test/suites/dev-tanssi-relay/staking/test_staking_rewards_balanced.ts (1)

170-189: Guard pendingRewards Option before dereferencing.

If the storage is None, toJSON() yields null and the test throws instead of failing with a clear assertion. Add an isSome check (and unwrap) so failures are explicit. Please confirm the Option API (isSome/unwrap) in the @polkadot/types version used.

♻️ Suggested guard
-                const pendingRewards = await polkadotJs.query.pooledStaking.pendingRewards();
-                const collatorPendingRewards = pendingRewards.toJSON().rewards[reward.accountId.toString()];
+                const pendingRewards = await polkadotJs.query.pooledStaking.pendingRewards();
+                expect(pendingRewards.isSome).toBe(true);
+                const collatorPendingRewards = pendingRewards.unwrap().toJSON().rewards[reward.accountId.toString()];
pallets/pooled-staking/src/tests/rewards.rs (2)

119-123: Avoid hardcoded distribution delay.

Using the configured delay keeps this test resilient if timing parameters change.

♻️ Suggested update
-    crate::PendingRewards::<Runtime>::put(crate::pools::PendingRewards {
-        last_distribution: <Runtime as crate::Config>::RewardsDistributionTimer::now() - 2,
-        rewards: Default::default(),
-    });
+    let delay = <Runtime as crate::Config>::RewardsDistributionSessionDelay::get();
+    let now = <Runtime as crate::Config>::RewardsDistributionTimer::now();
+    crate::PendingRewards::<Runtime>::put(crate::pools::PendingRewards {
+        last_distribution: now.saturating_sub(delay.saturating_add(1)),
+        rewards: Default::default(),
+    });

766-779: Advance blocks based on configured delay.

This test currently assumes a single-block delay; using the config constant avoids brittle timing.

♻️ Suggested update
-        // Let's move to next block, rewards should be distributed in on_initialize.
-        run_block();
-        assert_eq!(
-            <Runtime as crate::Config>::RewardsDistributionTimer::now(),
-            block_number + 1
-        );
+        // Advance by the configured delay so distribution timing stays in sync with config.
+        let delay = <Runtime as crate::Config>::RewardsDistributionSessionDelay::get();
+        for _ in 0..delay {
+            run_block();
+        }
+        assert_eq!(
+            <Runtime as crate::Config>::RewardsDistributionTimer::now(),
+            block_number + delay
+        );
 
         assert_eq!(
             crate::PendingRewards::<Runtime>::get(),
             Some(crate::pools::PendingRewards {
-                last_distribution: block_number + 1,
+                last_distribution: block_number + delay,
                 rewards: Default::default(), // distribution occured, no rewards pending
             })
         );
pallets/pooled-staking/src/lib.rs (1)

411-425: Unbounded storage acknowledged but verify operational limits.

The PendingRewards storage is explicitly marked unbounded with the rationale that it's "indirectly bounded" by the distribution timer. However, if the timer period is long or many collators receive rewards, this map could grow substantially, affecting PoV size.

Consider whether a hard cap on the number of pending entries is warranted as a safety measure, or ensure monitoring is in place to detect abnormal growth.

Comment on lines +177 to +185
const pendingRewards = await polkadotJs.query.pooledStaking.pendingRewards();
const collatorPendingRewards = pendingRewards.toJSON().rewards[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 + 2n);
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);

expect(collatorPendingRewards).to.equal(reward.balance.toJSON());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the test file to understand the context
cd test/suites/dev-tanssi-relay/staking && head -200 test_staking_rewards_non_balanced.ts | tail -50

Repository: moondance-labs/tanssi

Length of output: 2878


🏁 Script executed:

# Check the `@polkadot/api` version in use
find . -type f -name "package.json" -o -name "package-lock.json" | head -5

Repository: moondance-labs/tanssi

Length of output: 192


🏁 Script executed:

# Search for pendingRewards definition or type information
rg "pendingRewards" --type ts -B 2 -A 5

Repository: moondance-labs/tanssi

Length of output: 8505


🏁 Script executed:

# Search for usage of Option type methods like isSome, unwrap, isNone
rg "\.isSome|\.unwrap|\.isNone" --type ts test/ | head -20

Repository: moondance-labs/tanssi

Length of output: 1932


🏁 Script executed:

# Look for how Option is handled elsewhere in tests
rg "Option\<" --type ts test/ -B 2 -A 2 | head -40

Repository: moondance-labs/tanssi

Length of output: 145


🏁 Script executed:

# Check package.json for polkadot/api version
cat test/package.json | grep -A 2 "@polkadot/api"

Repository: moondance-labs/tanssi

Length of output: 160


🏁 Script executed:

# Search for PalletPooledStakingPoolsPendingRewards type definition to understand structure
rg "PalletPooledStakingPoolsPendingRewards" --type ts -B 2 -A 5 | head -40

Repository: moondance-labs/tanssi

Length of output: 3546


Guard pendingRewards Option before accessing .rewards.

The return type is Option<PalletPooledStakingPoolsPendingRewards>, which can be None when rewards are cleared. Accessing .toJSON().rewards without checking will cause a runtime error, making the test flaky. Use an isSome guard before unwrapping.

Suggested fix
-                const pendingRewards = await polkadotJs.query.pooledStaking.pendingRewards();
-                const collatorPendingRewards = pendingRewards.toJSON().rewards[reward.accountId.toString()];
+                const pendingRewardsOpt = await polkadotJs.query.pooledStaking.pendingRewards();
+                expect(pendingRewardsOpt.isSome).toBe(true);
+                const pendingRewards = pendingRewardsOpt.unwrap().toJSON();
+                const collatorPendingRewards = pendingRewards.rewards[reward.accountId.toString()];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pendingRewards = await polkadotJs.query.pooledStaking.pendingRewards();
const collatorPendingRewards = pendingRewards.toJSON().rewards[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 + 2n);
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);
expect(collatorPendingRewards).to.equal(reward.balance.toJSON());
const pendingRewardsOpt = await polkadotJs.query.pooledStaking.pendingRewards();
expect(pendingRewardsOpt.isSome).toBe(true);
const pendingRewards = pendingRewardsOpt.unwrap().toJSON();
const collatorPendingRewards = pendingRewards.rewards[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 + 2n);
expect(collatorPendingRewards).to.equal(reward.balance.toJSON());
🤖 Prompt for AI Agents
In `@test/suites/dev-tanssi-relay/staking/test_staking_rewards_non_balanced.ts`
around lines 177 - 185, The test accesses pendingRewards.toJSON().rewards
without checking the Option; guard the result of
polkadotJs.query.pooledStaking.pendingRewards() with its isSome/isNone before
unwrapping: call pendingRewards.isSome() and only read
pendingRewards.toJSON().rewards (and derive collatorPendingRewards) when it's
Some, otherwise treat collatorPendingRewards as undefined/null or an empty value
and adjust the assertion against reward (or assert pendingRewards.isSome() if
the test requires rewards to exist). Update references in the test to use the
safe-guarded value for collatorPendingRewards and avoid direct unwrapping of
pendingRewards.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pallets/pooled-staking/src/pools.rs`:
- Around line 532-562: The current distribute_accumulated_rewards function uses
core::mem::take(&mut pending_rewards.rewards) which clears all pending_rewards
up-front and loses unprocessed rewards if distribute_rewards::<T> fails; change
the logic to iterate over a borrowed list or drain entries one-by-one and only
remove each entry from pending_rewards.rewards after a successful
distribute_rewards::<T> call (or, on error, restore/leave the remaining entries
intact), i.e., avoid taking the whole map at start and instead process entries
atomically per candidate so pending_rewards.rewards retains unprocessed
candidates when an error is returned.
🧹 Nitpick comments (1)
chains/orchestrator-relays/runtime/dancelight/src/tests/inflation_rewards.rs (1)

115-115: Avoid hard-coded session indices in timer asserts.
Using Session::current_index() will keep these tests robust if session flow changes.

♻️ Suggested tweak (apply similarly to other asserts)
-assert_eq!(pending.last_distribution, 3u32, "timer should be up to date");
+assert_eq!(
+    pending.last_distribution,
+    Session::current_index(),
+    "timer should be up to date"
+);

Also applies to: 123-123, 179-179, 197-197

@nanocryk nanocryk added the breaking Needs to be mentioned in breaking changes label Jan 26, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pallets/pooled-staking/src/pools.rs`:
- Around line 541-566: The loop uses non-existent accessor methods and an
undefined variable `rewards`; replace calls to
`pending_rewards.rewards()`/`pending_rewards.get()` and `rewards.remove(...)`
with direct field accesses on the PendingRewards instance: collect keys from
`pending_rewards.rewards.keys().cloned().collect()`, fetch each reward with
`pending_rewards.rewards.get(&candidate).cloned().unwrap_or_default()`, and
remove the entry with `pending_rewards.rewards.remove(&candidate)`; keep the
call to `distribute_rewards::<T>(&candidate, reward)` and the weight updates
as-is.

@nanocryk nanocryk added the R6-orchestratorpara change affects flashbox and dancebox label Jan 26, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@pallets/pooled-staking/src/pools.rs`:
- Around line 608-619: Rename the test helper function
distribute_accumulated_rewards_immediatly to
distribute_accumulated_rewards_immediately and update all references to it
(e.g., in unit tests) to the new name; keep the same signature pub fn
distribute_accumulated_rewards_immediately<T: Config>() ->
DispatchResultWithPostInfo, preserve the #[cfg(test)] attribute and the internal
logic (using crate::PendingRewards::<T>::get/put and calling
distribute_accumulated_rewards::<T>(&mut pending)?). Ensure any test imports or
uses reference distribute_accumulated_rewards_immediately to avoid compilation
errors.
- Around line 532-573: The loop in distribute_accumulated_rewards removes
entries from the in-memory PendingRewards but does not persist those removals,
so a later retry (called by distribute_accumulated_rewards_on_timer) can re-read
already-committed per-candidate distributions; after each successful
distribute_rewards::<T>(&candidate, reward) call update the on-chain storage
representation of PendingRewards (persist pending_rewards) so the removed entry
is saved, or alternatively wrap the whole loop in a single storage transaction
to ensure all-or-nothing semantics; reference PendingRewards,
distribute_accumulated_rewards, distribute_rewards::<T>, and
distribute_accumulated_rewards_on_timer when making the persistence change.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pallets/pooled-staking/src/pools.rs`:
- Around line 579-615: The timed-distribution uses the ? operator on
distribute_accumulated_rewards, which causes an early return on error before
adding the base DB write and distribution weight; replace the `let post_info =
result?;` pattern in distribute_accumulated_rewards_on_timer<T: Config>() with
an explicit match on `result` (e.g. `match result { Ok(post_info) => post_info,
Err(e) => { weight = weight.saturating_add(T::DbWeight::get().writes(1)); return
Err(e); } }`) so that the PendingRewards write weight
(T::DbWeight::get().writes(1)) is always added on both success and error paths
and then add the distribution weight from `post_info.actual_weight` only after
the Ok branch; reference functions/types:
distribute_accumulated_rewards_on_timer, distribute_accumulated_rewards,
PendingRewards, T::DbWeight, T::RewardsDistributionTimer.
♻️ Duplicate comments (1)
pallets/pooled-staking/src/pools.rs (1)

617-628: Typo: immediatlyimmediately.
This was already flagged earlier; renaming would improve clarity (and update call sites).

📝 Suggested rename
-pub fn distribute_accumulated_rewards_immediatly<T: Config>() -> DispatchResultWithPostInfo {
+pub fn distribute_accumulated_rewards_immediately<T: Config>() -> DispatchResultWithPostInfo {

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@pallets/pooled-staking/src/pools.rs`:
- Around line 509-529: The weight returned from accumulate_rewards currently
only includes PendingRewards read/write but omits DB operations performed by
T::Currency::resolve(&T::StakingAccount::get(), ...) which may mutate storage;
update accumulate_rewards to account for the resolve() weight (either by adding
its read/write weight to the returned weight or ensuring the caller
documents/charges for those DB ops). Specifically, adjust the final Ok(...)
return in accumulate_rewards to include the DB weight consumed by
T::Currency::resolve (or call a helper that returns that weight) in addition to
T::DbWeight::get().reads(1) + writes(1), referencing the functions/symbols
accumulate_rewards, T::Currency::resolve, T::StakingAccount::get, and
crate::PendingRewards::<T>::put.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@chains/orchestrator-relays/runtime/starlight/src/tests/inflation_rewards.rs`:
- Around line 157-192: Replace the weak assert_ne! balance check with a precise
assertion using the precomputed rewards_per_chain: after run_to_session(5u32)
fetch balance_after via System::account(&account).data.free and assert that
balance_after - balance_before == rewards_per_chain (or use >= rewards_per_chain
if other incidental changes may apply); update the assertion around the pending
rewards block accordingly so the test verifies the exact distributed amount
using the rewards_per_chain variable.

Comment on lines 157 to 192
let all_rewards = RewardsPortion::get() * summary.inflation;
// rewards are shared between the 2 paras
let orchestrator_rewards = all_rewards / 2;
let candidate_rewards = RewardsCollatorCommission::get() * orchestrator_rewards;
let rewards_per_chain = all_rewards / 2; // rewards are shared between the 2 paras

// Rewards are now aggregated in storage and then distributed at the start of the next session.
// We'll thus check that the pending rewards contains DAVE rewards.

{
let pending = pallet_pooled_staking::PendingRewards::<Runtime>::get().expect("pending rewards to exist");
assert_eq!(pending.last_distribution, 4u32, "timer should be up to date");
let dave_rewards = pending.rewards.get(&account).expect("DAVE should have pending rewards");

assert_eq!(*dave_rewards, rewards_per_chain, "dave should have pending rewards for 1 chain");
}

let balance_after = System::account(&account).data.free;
assert_eq!(
candidate_rewards,
balance_after - balance_before,
"dave should get the correct reward portion"
balance_before,
balance_after,
"dave shouldn't have rewards in their account yet"
);

// let's go to next session
run_to_session(5u32);

{
let pending = pallet_pooled_staking::PendingRewards::<Runtime>::get().expect("pending rewards to exist");
assert_eq!(pending.last_distribution, 5u32, "timer should be up to date");
assert!(pending.rewards.is_empty(), "rewards should be distributed including the latest ones, so the pending rewards should be empty");
}

let balance_after = System::account(&account).data.free;
assert_ne!(
balance_before,
balance_after,
"dave should have rewards in their account now"
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Strengthen the post‑distribution balance assertion.

assert_ne! only checks that the balance changed; it won’t catch incorrect distribution amounts. Since rewards_per_chain is already computed, assert the exact delta (or use >= if other rewards could accrue between snapshots).

✅ Suggested fix (exact delta)
             let balance_after = System::account(&account).data.free;
-            assert_ne!(
-                balance_before,
-                balance_after,
-                "dave should have rewards in their account now"
-            );
+            assert_eq!(
+                rewards_per_chain,
+                balance_after - balance_before,
+                "dave should receive the expected rewards after distribution"
+            );
🤖 Prompt for AI Agents
In `@chains/orchestrator-relays/runtime/starlight/src/tests/inflation_rewards.rs`
around lines 157 - 192, Replace the weak assert_ne! balance check with a precise
assertion using the precomputed rewards_per_chain: after run_to_session(5u32)
fetch balance_after via System::account(&account).data.free and assert that
balance_after - balance_before == rewards_per_chain (or use >= rewards_per_chain
if other incidental changes may apply); update the assertion around the pending
rewards block accordingly so the test verifies the exact distributed amount
using the rewards_per_chain variable.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
`@chains/integration-tests/dancebox-runtime-tests/src/tests/integration_test.rs`:
- Around line 3766-3773: The test currently calls
pallet_pooled_staking::PendingRewards::<Runtime>::get().expect(...) which will
panic if storage is uninitialized; change it to tolerate None by checking the
Option first (e.g., use if let Some(pending) =
pallet_pooled_staking::PendingRewards::<Runtime>::get() {
assert!(pending.rewards.get(&account).is_none(), "..."); } else { /* no pending
rewards is acceptable */ } or use map_or to assert a true condition). This keeps
the check for the account's absence of rewards
(pending.rewards.get(&account).is_none()) while avoiding an expect panic when
PendingRewards is None.

Comment on lines +3766 to +3773
{
let pending = pallet_pooled_staking::PendingRewards::<Runtime>::get()
.expect("pending rewards to exist");
assert!(
pending.rewards.get(&account).is_none(),
"DAVE shouldn't have pending rewards at session start"
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "PendingRewards" pallets/pooled-staking/src/lib.rs -A 3 -B 1

Repository: moondance-labs/tanssi

Length of output: 325


🏁 Script executed:

rg -n "PendingRewards" pallets/pooled-staking/src/ --type rust -A 2 -B 1 | head -100

Repository: moondance-labs/tanssi

Length of output: 6942


🏁 Script executed:

# Search for where PendingRewards is initialized or accessed
rg -n "PendingRewards.*insert|PendingRewards.*put\|StorageValue.*PendingRewards" pallets/pooled-staking/src/

Repository: moondance-labs/tanssi

Length of output: 47


🏁 Script executed:

# Check the integration test setup and context around line 3766
sed -n '3700,3800p' chains/integration-tests/dancebox-runtime-tests/src/tests/integration_test.rs | cat -n

Repository: moondance-labs/tanssi

Length of output: 4621


Make the initial PendingRewards check tolerant to None.

The PendingRewards storage is not initialized at genesis and only created lazily when the pallet's reward functions are invoked. At this test point (session 4), expect() will panic if rewards haven't accrued yet, masking the actual test intent. The pallet code itself uses defensive patterns (unwrap_or_else(), if let Some(), pattern matching) when accessing PendingRewards. The test should follow the same approach:

Safer check
-            {
-                let pending = pallet_pooled_staking::PendingRewards::<Runtime>::get()
-                    .expect("pending rewards to exist");
-                assert!(
-                    pending.rewards.get(&account).is_none(),
-                    "DAVE shouldn't have pending rewards at session start"
-                );
-            }
+            {
+                if let Some(pending) = pallet_pooled_staking::PendingRewards::<Runtime>::get() {
+                    assert!(
+                        pending.rewards.get(&account).is_none(),
+                        "DAVE shouldn't have pending rewards at session start"
+                    );
+                }
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
let pending = pallet_pooled_staking::PendingRewards::<Runtime>::get()
.expect("pending rewards to exist");
assert!(
pending.rewards.get(&account).is_none(),
"DAVE shouldn't have pending rewards at session start"
);
}
{
if let Some(pending) = pallet_pooled_staking::PendingRewards::<Runtime>::get() {
assert!(
pending.rewards.get(&account).is_none(),
"DAVE shouldn't have pending rewards at session start"
);
}
}
🤖 Prompt for AI Agents
In
`@chains/integration-tests/dancebox-runtime-tests/src/tests/integration_test.rs`
around lines 3766 - 3773, The test currently calls
pallet_pooled_staking::PendingRewards::<Runtime>::get().expect(...) which will
panic if storage is uninitialized; change it to tolerate None by checking the
Option first (e.g., use if let Some(pending) =
pallet_pooled_staking::PendingRewards::<Runtime>::get() {
assert!(pending.rewards.get(&account).is_none(), "..."); } else { /* no pending
rewards is acceptable */ } or use map_or to assert a true condition). This keeps
the check for the account's absence of rewards
(pending.rewards.get(&account).is_none()) while avoiding an expect panic when
PendingRewards is None.

@github-actions
Copy link
Contributor

Coverage Report

(master)

@@                                   Coverage Diff                                    @@
##           master   jeremy-pooled-staking-aggregate-rewards-distrubution      +/-   ##
========================================================================================
+ Coverage   73.85%                                                 73.86%   +0.01%     
  Files         568                                                    568              
+ Lines       82793                                                  82887      +94     
========================================================================================
+ Hits        61141                                                  61219      +78     
+ Misses      21652                                                  21668      +16     
Files Changed Coverage
/chains/integration-tests/dancebox-runtime-tests/src/tests/integration_test.rs 99.56% (-0.06%)
/chains/orchestrator-relays/runtime/dancelight/src/tests/inflation_rewards.rs 98.45% (-0.26%)
/chains/orchestrator-relays/runtime/starlight/src/tests/common/mod.rs 91.16% (+0.01%)
/chains/orchestrator-relays/runtime/starlight/src/tests/inflation_rewards.rs 98.43% (-0.28%)
/pallets/inflation-rewards/src/lib.rs 81.21% (-1.21%)
/pallets/pooled-staking/src/candidate.rs 95.88% (-0.24%)
/pallets/pooled-staking/src/lib.rs 88.89% (-2.34%)
/pallets/pooled-staking/src/pools.rs 94.55% (-0.70%)
/pallets/pooled-staking/src/tests/candidates.rs 99.73% (-0.02%)
/pallets/pooled-staking/src/tests/rewards.rs 99.17% (+0.19%)

Coverage generated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a0-pleasereview Pull request needs code review. B7-runtimenoteworthy Changes should be noted in any runtime-upgrade release notes breaking Needs to be mentioned in breaking changes D9-needsaudit👮 PR contains changes to fund-managing logic that should be properly reviewed and externally audited R6-orchestratorpara change affects flashbox and dancebox R8-orchestratorsolo change affects dancelight and starlight only V-1800

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant