feat: M015 CosmWasm contribution-rewards contract with unit tests#72
feat: M015 CosmWasm contribution-rewards contract with unit tests#72brawlaphant wants to merge 1 commit intoregen-network:mainfrom
Conversation
Implements the Contribution-Weighted Rewards mechanism (M015) as a CosmWasm smart contract. Replaces passive staking with activity-based distribution from Community Pool, per the M015 SPEC. Key features: - Activity tracking for 5 contribution types (credit purchase, retirement, facilitation, governance votes, proposal submission) - Weighted scoring with configurable bps weights (default 30/30/20/10/10) - Proposal anti-gaming: full/half/zero credit based on quorum outcome - Epoch-based reward distribution proportional to activity scores - Stability tier: 6% annual return, 6-24 month lock, 50% early exit penalty - Stability cap at 30% of community pool inflow - Mechanism lifecycle: INACTIVE -> TRACKING -> DISTRIBUTING - Governance-controlled parameters, circuit breaker (pause/resume) - Transaction dedup via tx_hash tracking - Integer arithmetic only (Uint128, no floating point) 27 unit tests covering SPEC acceptance tests AT-1 through AT-22. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the contribution-rewards CosmWasm contract, which implements activity-based reward distribution and a stability tier for long-term commitments. The review identifies critical security and performance concerns, specifically a double-claiming vulnerability in stability rewards and potential gas exhaustion from O(N) iterations during epoch finalization. Further feedback points to missing validation for community pool inflows, a logic error in proposal credit calculations, and potential state bloat in transaction hash tracking.
| let existing = PENDING_STABILITY_REWARDS | ||
| .may_load(storage, &addr)? | ||
| .unwrap_or_default(); | ||
| PENDING_STABILITY_REWARDS.save(storage, &addr, &(existing + share))?; |
There was a problem hiding this comment.
Stability rewards are being added to both commitment.accrued_rewards and PENDING_STABILITY_REWARDS. Since execute_claim_rewards clears the latter and execute_claim_matured pays out the former, users can claim the same rewards twice. According to the SPEC (Section 6.2), stability rewards should be paid out upon maturity. You should remove the redundant tracking in PENDING_STABILITY_REWARDS.
| let participants: Vec<(String, bool)> = EPOCH_PARTICIPANTS | ||
| .prefix(epoch) | ||
| .range(deps.storage, None, None, Order::Ascending) | ||
| .collect::<StdResult<Vec<_>>>()?; | ||
|
|
||
| for (addr_str, _) in &participants { | ||
| let record = ACTIVITY_RECORDS.load(deps.storage, (epoch, addr_str.as_str()))?; | ||
| let score = compute_weighted_score(&record, &config.activity_weights); | ||
| if !score.is_zero() { | ||
| total_score += score; | ||
| let addr = deps.api.addr_validate(addr_str)?; | ||
| participant_scores.push((addr, score)); | ||
| } | ||
| } | ||
|
|
||
| // Distribute activity rewards proportionally | ||
| if state.mechanism_state == MechanismState::Distributing && !total_score.is_zero() { | ||
| for (addr, score) in &participant_scores { | ||
| let reward = activity_pool.multiply_ratio(*score, total_score); | ||
| if !reward.is_zero() { | ||
| let existing = PENDING_ACTIVITY_REWARDS | ||
| .may_load(deps.storage, addr.as_str())? | ||
| .unwrap_or_default(); | ||
| PENDING_ACTIVITY_REWARDS.save( | ||
| deps.storage, | ||
| addr.as_str(), | ||
| &(existing + reward), | ||
| )?; | ||
| } | ||
| } |
There was a problem hiding this comment.
This block performs an O(N) operation by iterating over all participants to distribute rewards. This will eventually exceed the block gas limit and prevent epoch finalization. \n\nRecommendation:\n1. Track aggregate activity values (e.g., total_credit_purchase_value) incrementally in execute_record_contribution to calculate total_score in O(1).\n2. Switch to a 'pull' model where users claim rewards for specific epochs, calculating their share on-the-fly.
| let commitments: Vec<(String, StabilityCommitment)> = STABILITY_COMMITMENTS | ||
| .range(storage, None, None, Order::Ascending) | ||
| .filter_map(|item| item.ok()) | ||
| .filter(|(_, c)| c.state == CommitmentState::Committed) | ||
| .collect(); | ||
|
|
||
| let total_committed: Uint128 = commitments.iter().map(|(_, c)| c.amount).sum(); | ||
|
|
||
| if total_committed.is_zero() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| for (addr, mut commitment) in commitments { | ||
| let share = stability_allocation.multiply_ratio(commitment.amount, total_committed); | ||
| commitment.accrued_rewards += share; | ||
| STABILITY_COMMITMENTS.save(storage, &addr, &commitment)?; | ||
|
|
||
| let existing = PENDING_STABILITY_REWARDS | ||
| .may_load(storage, &addr)? | ||
| .unwrap_or_default(); | ||
| PENDING_STABILITY_REWARDS.save(storage, &addr, &(existing + share))?; | ||
| } |
There was a problem hiding this comment.
Iterating over all stability commitments to update rewards is an O(N) operation that will lead to gas exhaustion. \n\nRecommendation:\nImplement a global reward index (e.g., global_index += epoch_rewards / total_committed). Each commitment should store its own reward_debt to allow O(1) reward calculation when the user interacts with the contract.
| if community_pool_inflow.is_zero() { | ||
| return Err(ContractError::ZeroInflow {}); | ||
| } |
| Some(ProposalOutcome::ReachedQuorumFailed) => Uint128::new(50), // 0.5 | ||
| Some(ProposalOutcome::FailedQuorum) | None => Uint128::zero(), // 0.0 | ||
| }; | ||
| record.proposal_credits_x100 += credit_x100; |
There was a problem hiding this comment.
| { | ||
| return Err(ContractError::DuplicateContribution { tx_hash }); | ||
| } | ||
| RECORDED_TX_HASHES.save(deps.storage, &tx_hash, &true)?; |
Summary
contracts/contribution-rewards/target/andCargo.lockto.gitignorefor Rust build artifactsContract Design
Activity tracking for 5 contribution types with configurable weights (bps):
Epoch management: Time-bounded reward periods with snapshots, calibration phase before distribution.
Stability tier: Optional commitment mechanism -- 6% annual return, 6-24 month lock, 50% early exit penalty, capped at 30% of community pool inflow.
Mechanism lifecycle:
INACTIVE -> TRACKING -> DISTRIBUTINGwith governance-controlled transitions and circuit breaker.Security: Transaction dedup via tx_hash, integer arithmetic only (Uint128), admin-gated mutations, all parameters governance-updatable.
Files
Cargo.tomlsrc/lib.rssrc/msg.rssrc/state.rssrc/error.rssrc/contract.rsTest Coverage (27 tests)
Maps to SPEC acceptance tests AT-1 through AT-22:
All 27 tests pass:
cargo testincontracts/contribution-rewards/Test plan
cargo buildcompiles without errorscargo test-- 27/27 tests passGenerated with Claude Code