From 559b988fcb51ba29daae28567901523450916fa2 Mon Sep 17 00:00:00 2001 From: akrem <71235284+akremstudy@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:12:53 -0400 Subject: [PATCH] Fix dispute fee distribution for multi rounds (#1030) * fix: refund dispute fees from round 1 amount, burn later round fees * Fix/dispute multiround review findings (#1032) * test: clean up multi-round dispute integration tests Dedupe the multi-round dispute test scaffolding ahead of the multi-round fee fixes: fold the from-bond round-1 starter into a payFromBond param, route round-1 setup through markRoundUnresolved, add proposeRound/fundedDisputer/seedTipper helpers for the repeated proposal and account blocks, and make setupDisputedReporter delegate to the multi-reporter variant. Replace the tipper validator account with a plain funded account (only its tips carry vote power), make vote execution deterministic instead of conditional, and drop debug prints and review-process commentary from test comments. Co-Authored-By: Claude Fable 5 * fix: do not track later-round dispute fees as refundable first-round stake AddDisputeRound paid escalation-round fees with isFirstRound=true, so a later round paid from bond appended its stake origins to the FeePaidFromStake tracker under the dispute's shared hash id. FeeRefund distributes the refundable round-1 fee pro-rata over that tracker, so a later-round bond payer could siphon part of the round-1 payer's refund (or leave a stale tracker when round 1 was account-funded). Later-round fees are fully consumed and never refunded, so pass isFirstRound=false. Adds regression tests covering account-funded and bond-funded round 1, the refund split after an INVALID resolution, and max-round escalation where every later round pays from bond. Co-Authored-By: Claude Fable 5 * fix: burn later-round fee pool when no claim-eligible voters exist ExecuteVote reserved half of BurnAmount as a voter reward whenever any vote group participated, including the team. CalculateReward only pays user and reporter voters, so a dispute resolved by team vote alone reserved an unclaimable balance in the dispute module forever; with later-round fees now accumulating into BurnAmount, that stranded amount grows with every escalation round. Gate the voter reward on claim-eligible participation instead: rename GetSumOfAllGroupVotesAllRounds to GetSumOfUserAndReporterVotesAllRounds and drop team votes from the sum, so team-only resolutions burn the entire consumed fee pool. Co-Authored-By: Claude Fable 5 * fix: resolve first-round fee payer in ClaimableDisputeRewards DisputeFeePayer records are only ever written under the round-1 dispute id, and WithdrawFeeRefund resolves PrevDisputeIds[0] before loading them. The ClaimableDisputeRewards query looked the payer up under the requested dispute id instead, so querying the final resolved round of a multi-round dispute reported a zero fee refund that the transaction path would actually pay. Resolve the first-round id in the query the same way the transaction does. Co-Authored-By: Claude Fable 5 * fix: report previous-round voter rewards in ClaimableDisputeRewards The query only called CalculateReward when the address had a Voter record under the requested dispute id, but ClaimReward has no such gate: CalculateReward scans every round via PrevDisputeIds, so an address that voted only in an earlier round of a multi-round dispute has a real claim that the query reported as zero. Use the final-round Voter record solely for the RewardClaimed flag (matching where ClaimReward stores it) and compute the reward regardless; CalculateReward already yields zero for non-participants. Includes a combined regression test where one address holds both a round-1 fee refund and a previous-round-only voter reward, queried by the final dispute id and then withdrawn through both transaction paths. Co-Authored-By: Claude Fable 5 * docs: correct round-1 fee split in ADR1011 and trim blank line at EOF Round 1's 5% does not all go to the burn pool: execution burns half and reserves half as the voter reward, and burns all of it when no users or reporters voted. Also remove the trailing blank line flagged by git diff --check. Co-Authored-By: Claude Fable 5 * fix: preserve previous-round voter rewards --------- Co-authored-by: Claude Fable 5 --------- Co-authored-by: Dan F Co-authored-by: Claude Fable 5 (cherry picked from commit 33c4113a6f28adb4ac7e01a965ec6062edd1d0af) --- adr/adr1011 - dispute round fees.md | 43 ++ tests/integration/dispute_keeper_test.go | 6 +- .../dispute_multiround_support_test.go | 625 ++++++++++++++++++ x/dispute/keeper/claim_reward.go | 17 +- x/dispute/keeper/dispute.go | 8 +- x/dispute/keeper/execute.go | 36 +- x/dispute/keeper/execute_test.go | 36 +- x/dispute/keeper/math_utils.go | 12 +- x/dispute/keeper/math_utils_test.go | 52 +- .../keeper/msg_server_withdraw_fee_refund.go | 12 +- x/dispute/keeper/query.go | 34 +- x/dispute/keeper/query_test.go | 5 +- 12 files changed, 782 insertions(+), 104 deletions(-) create mode 100644 adr/adr1011 - dispute round fees.md create mode 100644 tests/integration/dispute_multiround_support_test.go diff --git a/adr/adr1011 - dispute round fees.md b/adr/adr1011 - dispute round fees.md new file mode 100644 index 000000000..9f3291339 --- /dev/null +++ b/adr/adr1011 - dispute round fees.md @@ -0,0 +1,43 @@ +# ADR 1011: Dispute round fees + +## Authors + +@akrem + +## Changelog + +- 2026-06-10: initial version + +## Context + +A dispute can go through more than one round. Round 1 is the main dispute. If a round does not reach quorum someone can push it to another round by paying a fee, and that fee doubles every round. The chain caps this at 5 rounds. + +This ADR is about what the fee is for each round and what happens to it. + +Round 1's fee is refundable. You pay the dispute fee, which is the same as the slash amount. If the dispute resolves support you get your fee back minus a small burn and you also get the reporter's slashed tokens. If it resolves invalid you just get your fee back minus the burn. If it resolves against you the reporter gets your fee. So the round 1 fee comes back to you unless you lose the dispute. + +Rounds 2 and up are different. The fee you pay to start another round is not refundable to anyone. It is a burn. The whole fee gets consumed, half of it is actually burned and half goes to the voters of the dispute (if no users or reporters voted in any round, the voter half is burned as well). The only thing that is ever refundable is the round 1 fee. + +The fee for each extra round starts at 5% of the slash amount and doubles each round. So round 2 is 10% of the slash, round 3 is 20%, round 4 is 40%, round 5 is 80%, capped at 100%. It gets expensive fast on purpose. + +Why it works this way: + +If a dispute is extended to multiple rounds, the escalating fee does two things: + +1. The doubling fee keeps anyone from dragging a dispute out forever. Every extra round costs a lot more than the last one. +2. Half of the round fee goes to the voters to incentivize them to vote on the extra rounds. The fee grows as the rounds go up, so the reward for voting grows with it. Burning all of it instead would just destroy the tokens. + +The round 1 fee stays separate from all of this. It is the part that is actually at risk between the disputer and the reporter, and the final round decides what happens to it. The extra round fees are just the cost of asking for another vote. + + +## Issues / Notes on Implementation + +The fee schedule for reference, with s as the slash amount (5% is s/20): + +- Round 1: pay s. This is the refundable fee. 5% is consumed (half burned, half reserved as the voter reward; all of it burned if no users or reporters voted), 95% is refundable. +- Round 2: pay s/10 (10% of s). Fully consumed. +- Round 3: pay s/5 (20%). Fully consumed. +- Round 4: pay 2s/5 (40%). Fully consumed. +- Round 5: pay 4s/5 (80%). Fully consumed. + +Disputes are capped at 5 rounds. diff --git a/tests/integration/dispute_keeper_test.go b/tests/integration/dispute_keeper_test.go index 9512df5a5..4bd3fb7a6 100644 --- a/tests/integration/dispute_keeper_test.go +++ b/tests/integration/dispute_keeper_test.go @@ -1154,7 +1154,7 @@ func (s *IntegrationTestSuite) TestDisputeFiveRounds5thRdNewPayer() { s.NoError(err) s.Equal(types.Resolved, dispute.DisputeStatus) fmt.Println("DISPUTE FEE TOTAL", dispute.FeeTotal.String()) - expectedClaimAmount := dispute.FeeTotal.MulRaw(95).QuoRaw(100) + expectedClaimAmount := dispute.DisputeFee.MulRaw(95).QuoRaw(100) fmt.Println("EXPECTED CLAIM AMOUNT", expectedClaimAmount) _, err = s.Setup.App.BeginBlocker(s.Setup.Ctx) @@ -1535,7 +1535,7 @@ func (s *IntegrationTestSuite) TestDisputeFiveRounds1Payer() { s.NoError(err) s.Equal(types.Resolved, dispute.DisputeStatus) fmt.Println("DISPUTE FEE TOTAL", dispute.FeeTotal.String()) - expectedClaimAmount := dispute.FeeTotal.MulRaw(95).QuoRaw(100) + expectedClaimAmount := dispute.SlashAmount.MulRaw(95).QuoRaw(100) fmt.Println("EXPECTED CLAIM AMOUNT", expectedClaimAmount) _, err = s.Setup.App.BeginBlocker(s.Setup.Ctx) @@ -1945,7 +1945,7 @@ func (s *IntegrationTestSuite) TestDisputeFiveRoundsTwoFeePayers() { s.NoError(err) s.Equal(types.Resolved, dispute.DisputeStatus) fmt.Println("DISPUTE FEE TOTAL", dispute.FeeTotal.String()) - expectedClaimAmountTotal := dispute.FeeTotal.MulRaw(95).QuoRaw(100) + expectedClaimAmountTotal := dispute.DisputeFee.MulRaw(95).QuoRaw(100) fmt.Println("EXPECTED CLAIM AMOUNT TOTAL", expectedClaimAmountTotal) _, err = s.Setup.App.BeginBlocker(s.Setup.Ctx) diff --git a/tests/integration/dispute_multiround_support_test.go b/tests/integration/dispute_multiround_support_test.go new file mode 100644 index 000000000..0b88cafea --- /dev/null +++ b/tests/integration/dispute_multiround_support_test.go @@ -0,0 +1,625 @@ +package integration_test + +// Integration tests for multi-round dispute fee accounting. Round-1 fees are the only +// refundable fees; later (escalation) round fees are fully consumed as burn and voter +// rewards. + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/tellor-io/layer/x/dispute/keeper" + "github.com/tellor-io/layer/x/dispute/types" + oracletypes "github.com/tellor-io/layer/x/oracle/types" + reportertypes "github.com/tellor-io/layer/x/reporter/types" + + "cosmossdk.io/collections" + "cosmossdk.io/math" + + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +// setupDisputedReporterWithBondPayers registers a validator-reporter for each power and +// stores a disputable report for the first one. The extra reporters have stake of their +// own, so they can pay dispute fees from bond without disputing their own report. +func (s *IntegrationTestSuite) setupDisputedReporterWithBondPayers(powers ...uint64) ([]sdk.AccAddress, oracletypes.MicroReport, math.Int) { + repAccs, _, _ := s.createValidatorAccs(powers) + for i, reporter := range repAccs { + s.NoError(s.Setup.Reporterkeeper.Reporters.Set(s.Setup.Ctx, reporter, reportertypes.NewReporter(reportertypes.DefaultMinCommissionRate, math.OneInt(), fmt.Sprintf("reporter_moniker_%d", i)))) + s.NoError(s.Setup.Reporterkeeper.Selectors.Set(s.Setup.Ctx, reporter, reportertypes.NewSelection(reporter, 1))) + } + + disputedReporter := repAccs[0] + qId, _ := hex.DecodeString("83a7f3d48786ac2667503a61e8c415438ed2922eb86a2906e4ee66d9a2ce4992") + stake, err := s.Setup.Reporterkeeper.ReporterStake(s.Setup.Ctx, disputedReporter, qId) + s.NoError(err) + report := oracletypes.MicroReport{ + Reporter: disputedReporter.String(), + Power: stake.Quo(sdk.DefaultPowerReduction).Uint64(), + QueryId: qId, + Value: "000000000000000000000000000000000000000000000058528649cf80ee0000", + Timestamp: time.Now().Add(-1 * 12 * time.Hour), + BlockNumber: uint64(s.Setup.Ctx.BlockHeight()), + } + s.NoError(s.Setup.Oraclekeeper.Reports.Set(s.Setup.Ctx, collections.Join3(report.QueryId, disputedReporter.Bytes(), report.MetaId), report)) + fee, err := s.Setup.Disputekeeper.GetDisputeFee(s.Setup.Ctx, report, types.Warning) + s.NoError(err) + return repAccs, report, fee +} + +// setupDisputedReporter registers a single validator-reporter and stores a disputable report. +func (s *IntegrationTestSuite) setupDisputedReporter(power uint64) (sdk.AccAddress, oracletypes.MicroReport, math.Int) { + repAccs, report, fee := s.setupDisputedReporterWithBondPayers(power) + return repAccs[0], report, fee +} + +// fundedDisputer returns a fresh account with enough free tokens to pay dispute fees. +func (s *IntegrationTestSuite) fundedDisputer() sdk.AccAddress { + addr := s.newKeysWithTokens() + s.Setup.MintTokens(addr, math.NewInt(100_000_000)) + return addr +} + +// seedTipper credits addr with all tips at the report's block so the address carries +// user voting power for disputes over that report. +func (s *IntegrationTestSuite) seedTipper(addr sdk.AccAddress, report oracletypes.MicroReport) { + s.NoError(s.Setup.Oraclekeeper.TipperTotal.Set(s.Setup.Ctx, collections.Join(addr.Bytes(), report.BlockNumber), math.NewInt(100))) + s.NoError(s.Setup.Oraclekeeper.TotalTips.Set(s.Setup.Ctx, report.BlockNumber, math.NewInt(100))) +} + +// proposeRound submits a fully funded warning-category dispute proposal; while the +// dispute is open and unresolved this opens the next escalation round. +func (s *IntegrationTestSuite) proposeRound(msgServer types.MsgServer, creator sdk.AccAddress, report oracletypes.MicroReport, fee math.Int, payFromBond bool) { + _, err := msgServer.ProposeDispute(s.Setup.Ctx, &types.MsgProposeDispute{ + Creator: creator.String(), + DisputedReporter: report.Reporter, + ReportMetaId: report.MetaId, + ReportQueryId: hex.EncodeToString(report.QueryId), + Fee: sdk.NewCoin(s.Setup.Denom, fee), + DisputeCategory: types.Warning, + PayFromBond: payFromBond, + }) + s.NoError(err) +} + +// markRoundUnresolved gives the round a single INVALID vote that cannot reach quorum, +// waits out the voting period, and tallies, leaving the round Unresolved so the next +// escalation round can be opened. +func (s *IntegrationTestSuite) markRoundUnresolved(msgServer types.MsgServer, disputeId uint64, voter sdk.AccAddress) { + _, err := msgServer.Vote(s.Setup.Ctx, &types.MsgVote{Voter: voter.String(), Id: disputeId, Vote: types.VoteEnum_VOTE_INVALID}) + s.NoError(err) + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(s.Setup.Ctx.BlockTime().Add(keeper.TWO_DAYS + 1)) + s.NoError(s.Setup.Disputekeeper.TallyVote(s.Setup.Ctx, disputeId)) + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, disputeId) + s.NoError(err) + s.Equal(types.Unresolved, dispute.DisputeStatus, "round must be unresolved so the next escalation round can be opened") +} + +// startNoQuorumRound1 proposes a dispute fully funded by payer (optionally from bond), +// seeds the dispute's block info, and leaves round 1 Unresolved via a single no-quorum +// vote by voter. +func (s *IntegrationTestSuite) startNoQuorumRound1(msgServer types.MsgServer, report oracletypes.MicroReport, disputeFee math.Int, payer sdk.AccAddress, payFromBond bool, voter sdk.AccAddress) { + s.proposeRound(msgServer, payer, report, disputeFee, payFromBond) + d1, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 1) + s.NoError(err) + s.NoError(s.Setup.Disputekeeper.BlockInfo.Set(s.Setup.Ctx, d1.HashId, types.BlockInfo{TotalReporterPower: math.NewInt(int64(report.Power)).Mul(sdk.DefaultPowerReduction), TotalUserTips: math.NewInt(100)})) + s.markRoundUnresolved(msgServer, 1, voter) +} + +// voteAndTally casts a single vote on the round, waits out the voting period, and tallies. +func (s *IntegrationTestSuite) voteAndTally(msgServer types.MsgServer, disputeId uint64, voter sdk.AccAddress, vote types.VoteEnum) { + _, err := msgServer.Vote(s.Setup.Ctx, &types.MsgVote{Voter: voter.String(), Id: disputeId, Vote: vote}) + s.NoError(err) + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(s.Setup.Ctx.BlockTime().Add(keeper.THREE_DAYS + 1)) + s.NoError(s.Setup.Disputekeeper.TallyVote(s.Setup.Ctx, disputeId)) +} + +func feeTrackerContainsDelegator(tracker reportertypes.DelegationsAmounts, delegator sdk.AccAddress) bool { + for _, origin := range tracker.TokenOrigins { + if sdk.AccAddress(origin.DelegatorAddress).Equals(delegator) { + return true + } + } + return false +} + +// TestMultiRoundSupportRefundsRoundOneStakeOnly: on a multi-round SUPPORT resolution, +// the round-1 fee payer is refunded 95% of the round-1 fee and receives the full slashed +// bond, while a later-round payer gets nothing: escalation-round fees are fully consumed +// and never refundable. +func (s *IntegrationTestSuite) TestMultiRoundSupportRefundsRoundOneStakeOnly() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + disputer1 := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, disputer1, false, teamAddr) + + // round 2 funded by a different payer, then resolves SUPPORT + disputer2 := s.fundedDisputer() + s.proposeRound(msgServer, disputer2, report, disputeFee, false) + s.voteAndTally(msgServer, 2, teamAddr, types.VoteEnum_VOTE_SUPPORT) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + s.Equal(types.Resolved, dispute.DisputeStatus) + slashAmount := dispute.SlashAmount + + // the round-2 fee is consumed in full: + // BurnAmount = round-1's 5% (SlashAmount/20) + the entire round-2 fee (SlashAmount/10) + roundFee2 := slashAmount.QuoRaw(10) + s.Equal(slashAmount.QuoRaw(20).Add(roundFee2), dispute.BurnAmount) + + // round-1 payer: refunded 95% of the round-1 fee + the full slashed bond + expRefund, _ := keeper.CalculateRefundAmount(slashAmount, slashAmount) + bondedBefore, err := s.Setup.Stakingkeeper.TotalBondedTokens(s.Setup.Ctx) + s.NoError(err) + bal1Before := s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, disputer1, s.Setup.Denom) + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: disputer1.String(), CallerAddress: disputer1.String()}) + s.NoError(err) + s.Equal(expRefund, s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, disputer1, s.Setup.Denom).Amount.Sub(bal1Before.Amount)) + bondedAfter, err := s.Setup.Stakingkeeper.TotalBondedTokens(s.Setup.Ctx) + s.NoError(err) + s.Equal(slashAmount, bondedAfter.Sub(bondedBefore)) // entire slashed bond restaked to the round-1 payer + + // round-2 payer does not get the round-2 fee back + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: disputer2.String(), CallerAddress: disputer2.String()}) + s.Error(err) +} + +// TestMultiRoundSupportConservesWithVoterClaim: after a multi-round SUPPORT resolution, +// the round-1 payer claims the fee refund plus the slashed bond, the round-2 payer gets +// nothing, and the voter claims the voter reward (half of BurnAmount: 2.5% of the +// round-1 fee plus half of the round-2 fee). All claims together drain the dispute +// module to ~zero. +func (s *IntegrationTestSuite) TestMultiRoundSupportConservesWithVoterClaim() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + tipper := s.fundedDisputer() + s.seedTipper(tipper, report) + + disputer1 := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, disputer1, false, teamAddr) + + disputer2 := s.fundedDisputer() + s.proposeRound(msgServer, disputer2, report, disputeFee, false) + _, err = msgServer.Vote(s.Setup.Ctx, &types.MsgVote{Voter: teamAddr.String(), Id: 2, Vote: types.VoteEnum_VOTE_SUPPORT}) + s.NoError(err) + _, err = msgServer.Vote(s.Setup.Ctx, &types.MsgVote{Voter: tipper.String(), Id: 2, Vote: types.VoteEnum_VOTE_SUPPORT}) + s.NoError(err) + + // team power plus all user power reach quorum, so the round resolves without + // waiting out the voting period + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + s.Equal(types.Resolved, dispute.DisputeStatus) + s.True(dispute.PendingExecution) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + vote, err := s.Setup.Disputekeeper.Votes.Get(s.Setup.Ctx, 2) + s.NoError(err) + s.Equal(types.VoteResult_SUPPORT, vote.VoteResult) + dispute, err = s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + + // the voter reward is half of the consumed pool, which includes the round-2 fee + s.Equal(dispute.BurnAmount.QuoRaw(2), dispute.VoterReward) + s.True(dispute.VoterReward.GT(dispute.SlashAmount.QuoRaw(40))) // more than round-1's voter half alone + + disputeModAddr := authtypes.NewModuleAddress(types.ModuleName) + + // round-1 payer claims refund + bond + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: disputer1.String(), CallerAddress: disputer1.String()}) + s.NoError(err) + // round-2 payer is not refunded the fee + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: disputer2.String(), CallerAddress: disputer2.String()}) + s.Error(err) + // voter claims the voter reward + _, err = msgServer.ClaimReward(s.Setup.Ctx, &types.MsgClaimReward{CallerAddress: tipper.String(), DisputeId: 2}) + s.NoError(err) + + modFinal := s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, disputeModAddr, s.Setup.Denom) + s.True(modFinal.Amount.LTE(math.NewInt(1)), "dispute module should net to ~zero after all claims, has %s", modFinal.Amount) +} + +// TestMultiRoundAgainstReporterGetsRoundOneStakeOnly: when a multi-round dispute +// resolves AGAINST, the reporter is awarded their bond back plus 95% of the round-1 fee, +// while later-round fees stay consumed. Disputers get nothing. +func (s *IntegrationTestSuite) TestMultiRoundAgainstReporterGetsRoundOneStakeOnly() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + disputer1 := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, disputer1, false, teamAddr) + + // round 2 funded by a different payer + disputer2 := s.fundedDisputer() + s.proposeRound(msgServer, disputer2, report, disputeFee, false) + s.voteAndTally(msgServer, 2, teamAddr, types.VoteEnum_VOTE_AGAINST) + + disputeModAddr := authtypes.NewModuleAddress(types.ModuleName) + bondedBefore, err := s.Setup.Stakingkeeper.TotalBondedTokens(s.Setup.Ctx) + s.NoError(err) + + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + vote, err := s.Setup.Disputekeeper.Votes.Get(s.Setup.Ctx, 2) + s.NoError(err) + s.Equal(types.VoteResult_NO_QUORUM_MAJORITY_AGAINST, vote.VoteResult) + bondedAfter, err := s.Setup.Stakingkeeper.TotalBondedTokens(s.Setup.Ctx) + s.NoError(err) + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + + // reporter gets bond back + 95% of the round-1 fee + expectedReporterGain := dispute.SlashAmount.Add(dispute.DisputeFee.Sub(dispute.DisputeFee.QuoRaw(20))) + s.Equal(expectedReporterGain, bondedAfter.Sub(bondedBefore)) + + // only the reserved voter reward remains in the dispute module + s.Equal(dispute.VoterReward, s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, disputeModAddr, s.Setup.Denom).Amount) + + // disputers get nothing on AGAINST + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: disputer1.String(), CallerAddress: disputer1.String()}) + s.Error(err) + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: disputer2.String(), CallerAddress: disputer2.String()}) + s.Error(err) +} + +// TestMultiRoundPayFromBondDoesNotTrackLaterRoundAsRefundableStake: the +// FeePaidFromStake tracker exists so FeeRefund can restake the refundable round-1 fee. +// With round 1 funded from an account there is no tracker, and a later round paid from +// bond must not create one: escalation-round fees are fully consumed. +func (s *IntegrationTestSuite) TestMultiRoundPayFromBondDoesNotTrackLaterRoundAsRefundableStake() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + reporters, report, disputeFee := s.setupDisputedReporterWithBondPayers(100, 100) + roundTwoBondPayer := reporters[1] + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + roundOnePayer := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, roundOnePayer, false, teamAddr) + + firstRoundDispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 1) + s.NoError(err) + hasTrackedStakeFee, err := s.Setup.Reporterkeeper.FeePaidFromStake.Has(s.Setup.Ctx, firstRoundDispute.HashId) + s.NoError(err) + s.False(hasTrackedStakeFee, "round 1 was account-funded, so no stake-fee refund tracker should exist before later rounds") + + s.proposeRound(msgServer, roundTwoBondPayer, report, disputeFee, true) + + hasTrackedStakeFee, err = s.Setup.Reporterkeeper.FeePaidFromStake.Has(s.Setup.Ctx, firstRoundDispute.HashId) + s.NoError(err) + s.False(hasTrackedStakeFee, "later-round fees paid from bond are non-refundable and must not be tracked as refundable round-1 stake") +} + +// TestMultiRoundPayFromBondDoesNotAppendToFirstRoundStakeRefundTracker: when round 1 +// itself was paid from bond, FeePaidFromStake holds a snapshot of the round-1 stake +// origins. Opening a later round from bond must leave that snapshot untouched. +func (s *IntegrationTestSuite) TestMultiRoundPayFromBondDoesNotAppendToFirstRoundStakeRefundTracker() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + reporters, report, disputeFee := s.setupDisputedReporterWithBondPayers(100, 100, 100) + roundOneBondPayer := reporters[1] + roundTwoBondPayer := reporters[2] + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + s.startNoQuorumRound1(msgServer, report, disputeFee, roundOneBondPayer, true, teamAddr) + firstRoundDispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 1) + s.NoError(err) + trackerBeforeRound2, err := s.Setup.Reporterkeeper.FeePaidFromStake.Get(s.Setup.Ctx, firstRoundDispute.HashId) + s.NoError(err) + s.Equal(disputeFee, trackerBeforeRound2.Total, "round 1 paid from bond should track exactly the refundable round-1 fee") + s.True(feeTrackerContainsDelegator(trackerBeforeRound2, roundOneBondPayer)) + s.False(feeTrackerContainsDelegator(trackerBeforeRound2, roundTwoBondPayer)) + + s.proposeRound(msgServer, roundTwoBondPayer, report, disputeFee, true) + + trackerAfterRound2, err := s.Setup.Reporterkeeper.FeePaidFromStake.Get(s.Setup.Ctx, firstRoundDispute.HashId) + s.NoError(err) + s.Equal(trackerBeforeRound2.Total, trackerAfterRound2.Total, "later-round fees paid from bond must not increase the refundable round-1 total") + s.Equal(len(trackerBeforeRound2.TokenOrigins), len(trackerAfterRound2.TokenOrigins), "later-round fees paid from bond must not append token origins to the round-1 refund tracker") + s.False(feeTrackerContainsDelegator(trackerAfterRound2, roundTwoBondPayer), "a later-round bond payer must not become a refund-tracker delegator") +} + +// TestMultiRoundPayFromBondRefundDoesNotRestakeLaterRoundBondPayer: when the round-1 +// payer withdraws an INVALID-resolution refund, only round-1 stake origins are restored. +// A later-round bond payer's fee was consumed by escalation and must not be restaked +// from the round-1 refund. +func (s *IntegrationTestSuite) TestMultiRoundPayFromBondRefundDoesNotRestakeLaterRoundBondPayer() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + reporters, report, disputeFee := s.setupDisputedReporterWithBondPayers(100, 100, 100) + roundOneBondPayer := reporters[1] + roundTwoBondPayer := reporters[2] + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + s.startNoQuorumRound1(msgServer, report, disputeFee, roundOneBondPayer, true, teamAddr) + s.proposeRound(msgServer, roundTwoBondPayer, report, disputeFee, true) + + // resolve the final round INVALID: the round-1 fee is refunded to stake without + // adding the disputed reporter's slashed bond, isolating the refund split across + // FeePaidFromStake origins + s.voteAndTally(msgServer, 2, teamAddr, types.VoteEnum_VOTE_INVALID) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + roundOneStakeBefore, err := s.Setup.Reporterkeeper.ReporterStake(s.Setup.Ctx, roundOneBondPayer, report.QueryId) + s.NoError(err) + roundTwoStakeBefore, err := s.Setup.Reporterkeeper.ReporterStake(s.Setup.Ctx, roundTwoBondPayer, report.QueryId) + s.NoError(err) + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: roundOneBondPayer.String(), CallerAddress: roundOneBondPayer.String()}) + s.NoError(err) + roundOneStakeAfter, err := s.Setup.Reporterkeeper.ReporterStake(s.Setup.Ctx, roundOneBondPayer, report.QueryId) + s.NoError(err) + roundTwoStakeAfter, err := s.Setup.Reporterkeeper.ReporterStake(s.Setup.Ctx, roundTwoBondPayer, report.QueryId) + s.NoError(err) + + s.True(roundOneStakeAfter.GT(roundOneStakeBefore), "the round-1 bond payer should receive a stake refund") + s.Equal(roundTwoStakeBefore, roundTwoStakeAfter, "a later-round bond payer must not receive stake from the round-1 fee refund") +} + +// TestWorstCaseMultiRoundPayFromBondMaxRoundsDoesNotDiluteFirstRoundStakeRefund: round 1 +// is paid from bond, then every later round up to the round cap is opened by a different +// bond payer. The round-1 refund tracker must still contain only the original round-1 +// payer and amount. +func (s *IntegrationTestSuite) TestWorstCaseMultiRoundPayFromBondMaxRoundsDoesNotDiluteFirstRoundStakeRefund() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + reporters, report, disputeFee := s.setupDisputedReporterWithBondPayers(100, 100, 100, 100, 100, 100) + roundOneBondPayer := reporters[1] + laterRoundBondPayers := reporters[2:] + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + s.startNoQuorumRound1(msgServer, report, disputeFee, roundOneBondPayer, true, teamAddr) + firstRoundDispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 1) + s.NoError(err) + trackerBeforeEscalation, err := s.Setup.Reporterkeeper.FeePaidFromStake.Get(s.Setup.Ctx, firstRoundDispute.HashId) + s.NoError(err) + + for roundId := uint64(2); roundId <= 5; roundId++ { + s.proposeRound(msgServer, laterRoundBondPayers[roundId-2], report, disputeFee, true) + if roundId < 5 { + s.markRoundUnresolved(msgServer, roundId, teamAddr) + } + } + + trackerAfterMaxEscalation, err := s.Setup.Reporterkeeper.FeePaidFromStake.Get(s.Setup.Ctx, firstRoundDispute.HashId) + s.NoError(err) + s.Equal(trackerBeforeEscalation.Total, trackerAfterMaxEscalation.Total, "max escalation from bond must not dilute the refundable round-1 total") + s.Equal(len(trackerBeforeEscalation.TokenOrigins), len(trackerAfterMaxEscalation.TokenOrigins), "max escalation from bond must not add refund recipients") + for _, payer := range laterRoundBondPayers { + s.False(feeTrackerContainsDelegator(trackerAfterMaxEscalation, payer), "later-round bond payer %s must not be in the round-1 refund tracker", payer.String()) + } +} + +// TestMultiRoundTeamOnlyFinalRoundDoesNotCreateUnclaimableVoterReward: team votes can +// decide a dispute but cannot claim voter rewards, so a team-only resolution must burn +// the entire consumed fee pool instead of reserving an unclaimable voter half in the +// dispute module. +func (s *IntegrationTestSuite) TestMultiRoundTeamOnlyFinalRoundDoesNotCreateUnclaimableVoterReward() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + disputer1 := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, disputer1, false, teamAddr) + + // round 2 is account-funded and resolved by a team-only vote + disputer2 := s.fundedDisputer() + s.proposeRound(msgServer, disputer2, report, disputeFee, false) + s.voteAndTally(msgServer, 2, teamAddr, types.VoteEnum_VOTE_AGAINST) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + s.Equal(types.Resolved, dispute.DisputeStatus) + s.True(dispute.VoterReward.IsZero(), "team-only resolutions have no claimable voters, so no voter reward should be reserved") + + disputeModAddr := authtypes.NewModuleAddress(types.ModuleName) + modBalance := s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, disputeModAddr, s.Setup.Denom) + s.True(modBalance.Amount.IsZero(), "later-round fees should be fully consumed rather than stranded in the dispute module") +} + +// TestWorstCaseTeamOnlyMaxRoundDoesNotStrandEscalatedVoterRewards: after several +// escalation rounds the consumed fee pool is large. If the final round resolves with +// only a team vote, the whole pool must be burned; none of it may stay stranded in the +// dispute module as an unclaimable voter reward. +func (s *IntegrationTestSuite) TestWorstCaseTeamOnlyMaxRoundDoesNotStrandEscalatedVoterRewards() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + roundOnePayer := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, roundOnePayer, false, teamAddr) + + for roundId := uint64(2); roundId <= 5; roundId++ { + s.proposeRound(msgServer, s.fundedDisputer(), report, disputeFee, false) + if roundId < 5 { + s.markRoundUnresolved(msgServer, roundId, teamAddr) + } + } + + s.voteAndTally(msgServer, 5, teamAddr, types.VoteEnum_VOTE_AGAINST) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 5)) + + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 5) + s.NoError(err) + s.Equal(types.Resolved, dispute.DisputeStatus) + s.True(dispute.VoterReward.IsZero(), "team-only resolutions have no claimable voters, so no voter reward should be reserved") + + disputeModAddr := authtypes.NewModuleAddress(types.ModuleName) + modBalance := s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, disputeModAddr, s.Setup.Denom) + s.True(modBalance.Amount.IsZero(), "escalation fees must be fully consumed rather than stranded in the dispute module") +} + +// TestMultiRoundFinalRoundNoVotesStillRewardsPreviousRoundVoters: if the final round +// expires with no votes, previous-round user/reporter voters are still claim-eligible, +// so the voter half of the consumed fee pool must be reserved and claimable. +func (s *IntegrationTestSuite) TestMultiRoundFinalRoundNoVotesStillRewardsPreviousRoundVoters() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + + previousRoundVoter := s.fundedDisputer() + s.seedTipper(previousRoundVoter, report) + + roundOnePayer := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, roundOnePayer, false, previousRoundVoter) + + roundTwoPayer := s.fundedDisputer() + s.proposeRound(msgServer, roundTwoPayer, report, disputeFee, false) + + hasFinalRoundVoteCounts, err := s.Setup.Disputekeeper.VoteCountsByGroup.Has(s.Setup.Ctx, 2) + s.NoError(err) + s.False(hasFinalRoundVoteCounts, "final round must have no vote counts for this regression") + + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(s.Setup.Ctx.BlockTime().Add(keeper.THREE_DAYS + 1)) + s.NoError(s.Setup.Disputekeeper.TallyVote(s.Setup.Ctx, 2)) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + s.Equal(types.Resolved, dispute.DisputeStatus) + s.Equal(dispute.BurnAmount.QuoRaw(2), dispute.VoterReward, "previous-round voters should keep the voter reward claimable") + + expectedReward, err := s.Setup.Disputekeeper.CalculateReward(s.Setup.Ctx, previousRoundVoter, 2) + s.Require().NoError(err) + s.True(expectedReward.IsPositive(), "previous-round voter should have a claimable reward") + + balBefore := s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, previousRoundVoter, s.Setup.Denom) + _, err = msgServer.ClaimReward(s.Setup.Ctx, &types.MsgClaimReward{CallerAddress: previousRoundVoter.String(), DisputeId: 2}) + s.NoError(err) + balAfter := s.Setup.Bankkeeper.GetBalance(s.Setup.Ctx, previousRoundVoter, s.Setup.Denom) + s.Equal(expectedReward, balAfter.Amount.Sub(balBefore.Amount)) +} + +// TestClaimableDisputeRewardsUsesFirstRoundFeePayerForFinalRound: fee payer records +// live under the first-round dispute id, but wallets query the final resolved round id. +// The query must resolve the round-1 payer the same way WithdrawFeeRefund does. +func (s *IntegrationTestSuite) TestClaimableDisputeRewardsUsesFirstRoundFeePayerForFinalRound() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + disputer1 := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, disputer1, false, teamAddr) + + disputer2 := s.fundedDisputer() + s.proposeRound(msgServer, disputer2, report, disputeFee, false) + s.voteAndTally(msgServer, 2, teamAddr, types.VoteEnum_VOTE_SUPPORT) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + dispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + refund, _ := keeper.CalculateRefundAmount(dispute.SlashAmount, dispute.SlashAmount) + expectedClaimable := refund.Add(dispute.SlashAmount) + + queryServer := keeper.NewQuerier(s.Setup.Disputekeeper) + resp, err := queryServer.ClaimableDisputeRewards(s.Setup.Ctx, &types.QueryClaimableDisputeRewardsRequest{ + DisputeId: 2, + Address: disputer1.String(), + }) + s.NoError(err) + s.Equal(expectedClaimable, resp.ClaimableAmount.FeeRefundAmount, "final-round query should report the round-1 payer's fee refund plus reporter bond reward") +} + +// TestClaimableDisputeRewardsIncludesPreviousRoundOnlyVoter: ClaimReward pays voters +// from any round by scanning PrevDisputeIds, so the query must not require a Voter +// record under the final round id before reporting the reward. +func (s *IntegrationTestSuite) TestClaimableDisputeRewardsIncludesPreviousRoundOnlyVoter() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + previousRoundOnlyVoter := s.fundedDisputer() + s.seedTipper(previousRoundOnlyVoter, report) + + // round 1 is voted on (and left unresolved) by the tipper, not the team + roundOnePayer := s.fundedDisputer() + s.startNoQuorumRound1(msgServer, report, disputeFee, roundOnePayer, false, previousRoundOnlyVoter) + + // the tipper does not vote in round 2; the team resolves it + disputer2 := s.fundedDisputer() + s.proposeRound(msgServer, disputer2, report, disputeFee, false) + s.voteAndTally(msgServer, 2, teamAddr, types.VoteEnum_VOTE_SUPPORT) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + expectedReward, err := s.Setup.Disputekeeper.CalculateReward(s.Setup.Ctx, previousRoundOnlyVoter, 2) + s.NoError(err) + s.True(expectedReward.IsPositive(), "the previous-round-only voter should have a real claimable reward") + + queryServer := keeper.NewQuerier(s.Setup.Disputekeeper) + resp, err := queryServer.ClaimableDisputeRewards(s.Setup.Ctx, &types.QueryClaimableDisputeRewardsRequest{ + DisputeId: 2, + Address: previousRoundOnlyVoter.String(), + }) + s.NoError(err) + s.Equal(expectedReward, resp.ClaimableAmount.RewardAmount, "the query should report rewards for voters who only participated in previous rounds") +} + +// TestWorstCaseClaimableDisputeRewardsShowsCombinedPreviousRoundClaims: one address is +// both the round-1 fee payer and a previous-round-only voter, and the user queries the +// final dispute id. The query must report both the fee refund and the voter reward, and +// both amounts must actually be withdrawable through the transaction paths. +func (s *IntegrationTestSuite) TestWorstCaseClaimableDisputeRewardsShowsCombinedPreviousRoundClaims() { + s.Setup.Ctx = s.Setup.Ctx.WithBlockTime(time.Now()) + msgServer := keeper.NewMsgServerImpl(s.Setup.Disputekeeper) + _, report, disputeFee := s.setupDisputedReporter(100) + teamAddr, err := s.Setup.Disputekeeper.GetTeamAddress(s.Setup.Ctx) + s.NoError(err) + + // the claimant pays the round-1 fee and is the only round-1 voter + claimant := s.fundedDisputer() + s.seedTipper(claimant, report) + s.startNoQuorumRound1(msgServer, report, disputeFee, claimant, false, claimant) + + roundTwoPayer := s.fundedDisputer() + s.proposeRound(msgServer, roundTwoPayer, report, disputeFee, false) + s.voteAndTally(msgServer, 2, teamAddr, types.VoteEnum_VOTE_SUPPORT) + s.NoError(s.Setup.Disputekeeper.ExecuteVote(s.Setup.Ctx, 2)) + + finalDispute, err := s.Setup.Disputekeeper.Disputes.Get(s.Setup.Ctx, 2) + s.NoError(err) + expectedReward, err := s.Setup.Disputekeeper.CalculateReward(s.Setup.Ctx, claimant, 2) + s.NoError(err) + s.True(expectedReward.IsPositive(), "the claimant should have a real previous-round-only voter reward") + expectedRefund, _ := keeper.CalculateRefundAmount(finalDispute.SlashAmount, finalDispute.SlashAmount) + expectedFeeRefund := expectedRefund.Add(finalDispute.SlashAmount) + + queryServer := keeper.NewQuerier(s.Setup.Disputekeeper) + resp, err := queryServer.ClaimableDisputeRewards(s.Setup.Ctx, &types.QueryClaimableDisputeRewardsRequest{ + DisputeId: 2, + Address: claimant.String(), + }) + s.NoError(err) + s.Equal(expectedFeeRefund, resp.ClaimableAmount.FeeRefundAmount, "final-id query must show the round-1 fee refund plus reporter bond reward") + s.Equal(expectedReward, resp.ClaimableAmount.RewardAmount, "final-id query must show the previous-round-only voter reward") + + // the amounts the query reports are really withdrawable through the tx paths + _, err = msgServer.WithdrawFeeRefund(s.Setup.Ctx, &types.MsgWithdrawFeeRefund{Id: 2, PayerAddress: claimant.String(), CallerAddress: claimant.String()}) + s.NoError(err) + _, err = msgServer.ClaimReward(s.Setup.Ctx, &types.MsgClaimReward{CallerAddress: claimant.String(), DisputeId: 2}) + s.NoError(err) +} diff --git a/x/dispute/keeper/claim_reward.go b/x/dispute/keeper/claim_reward.go index 2b68daacb..dd057d6c7 100644 --- a/x/dispute/keeper/claim_reward.go +++ b/x/dispute/keeper/claim_reward.go @@ -79,6 +79,16 @@ func (k Keeper) CalculateReward(ctx sdk.Context, addr sdk.AccAddress, id uint64) globalUserPower := math.ZeroInt() for _, pastId := range dispute.PrevDisputeIds { + // Get global vote counts for the past dispute. A missing row means that + // round had no user/reporter votes to reward. + pastVoteCounts, err := k.VoteCountsByGroup.Get(ctx, pastId) + if err != nil { + if errors.Is(err, collections.ErrNotFound) { + continue + } + return math.Int{}, err + } + pastVoterInfo, err := k.Voter.Get(ctx, collections.Join(pastId, addr.Bytes())) if err == nil { // Voter info exists for this past dispute @@ -88,13 +98,10 @@ func (k Keeper) CalculateReward(ctx sdk.Context, addr sdk.AccAddress, id uint64) return math.Int{}, err } addrUserPower = addrUserPower.Add(userTips) - } - - // Get global vote counts for the past dispute - pastVoteCounts, err := k.VoteCountsByGroup.Get(ctx, pastId) - if err != nil { + } else if !errors.Is(err, collections.ErrNotFound) { return math.Int{}, err } + // Add up the global power for each group globalReporterPower = globalReporterPower.Add(math.NewIntFromUint64(pastVoteCounts.Reporters.Support)). Add(math.NewIntFromUint64(pastVoteCounts.Reporters.Against)).Add(math.NewIntFromUint64(pastVoteCounts.Reporters.Invalid)) diff --git a/x/dispute/keeper/dispute.go b/x/dispute/keeper/dispute.go index 72fc33c98..206f5041d 100644 --- a/x/dispute/keeper/dispute.go +++ b/x/dispute/keeper/dispute.go @@ -258,8 +258,9 @@ func (k Keeper) AddDisputeRound(ctx sdk.Context, sender sdk.AccAddress, dispute msg.Fee.Amount = roundFee } - // Pay the dispute fee - if err := k.PayDisputeFee(ctx, sender, msg.Fee, msg.PayFromBond, dispute.HashId, true); err != nil { + // Pay the dispute fee. Later-round fees are fully consumed (never refunded), so they + // must not be tracked as refundable first-round stake. + if err := k.PayDisputeFee(ctx, sender, msg.Fee, msg.PayFromBond, dispute.HashId, false); err != nil { return err } @@ -268,7 +269,8 @@ func (k Keeper) AddDisputeRound(ctx sdk.Context, sender sdk.AccAddress, dispute } prevDisputeId := dispute.DisputeId - dispute.BurnAmount = dispute.BurnAmount.Add(fivePercent) // burnAmt = 5 % of fee total + dispute.BurnAmount = dispute.BurnAmount.Add(roundFee) + // FeeTotal is informationl only, tracks total fees paid across rounds. dispute.FeeTotal = dispute.FeeTotal.Add(msg.Fee.Amount) disputeId := k.NextDisputeId(ctx) dispute.DisputeId = disputeId diff --git a/x/dispute/keeper/execute.go b/x/dispute/keeper/execute.go index 50cb4bb9a..4881b7121 100644 --- a/x/dispute/keeper/execute.go +++ b/x/dispute/keeper/execute.go @@ -8,6 +8,7 @@ import ( layertypes "github.com/tellor-io/layer/types" "github.com/tellor-io/layer/x/dispute/types" + "cosmossdk.io/collections" "cosmossdk.io/math" sdk "github.com/cosmos/cosmos-sdk/types" @@ -48,17 +49,20 @@ func (k Keeper) ExecuteVote(ctx context.Context, id uint64) error { return errors.New("vote already executed") } - // the burnAmount starts at %5 of disputeFee, half of which is burned and the other half is distributed to the voters + // the burnAmount is round 1's 5% of the dispute fee plus all later-round fees, half + // of which is burned and the other half is distributed to the voters disputeBurnAmountDec := math.LegacyNewDecFromInt(dispute.BurnAmount) halfBurnAmountDec := disputeBurnAmountDec.Quo(math.LegacyNewDec(2)) halfBurnAmount := halfBurnAmountDec.TruncateInt() voterReward := halfBurnAmount - totalVoterPower, err := k.GetSumOfAllGroupVotesAllRounds(ctx, id) + // only user and reporter votes can claim through CalculateReward; team votes decide + // outcomes but earn nothing, so they must not cause a voter reward to be reserved + totalVoterPower, err := k.GetSumOfUserAndReporterVotesAllRounds(ctx, id) if err != nil { return err } if totalVoterPower.IsZero() { - // if no voters, burn the entire burnAmount + // if no claim-eligible voters, burn the entire burnAmount halfBurnAmount = dispute.BurnAmount // non voters get nothing voterReward = math.ZeroInt() @@ -153,8 +157,8 @@ func (k Keeper) RefundFailedDisputeFee(ctx context.Context, feePayer sdk.AccAddr return k.ReturnFeetoStake(ctx, hashId, fee) } -func (k Keeper) RefundDisputeFee(ctx context.Context, feePayer sdk.AccAddress, payerInfo types.PayerInfo, disputeFeeTotal math.Int, hashId []byte, slashAmt math.Int) (math.Int, error) { - amtFixed6, remainder := CalculateRefundAmount(payerInfo.Amount, slashAmt, disputeFeeTotal) +func (k Keeper) RefundDisputeFee(ctx context.Context, feePayer sdk.AccAddress, payerInfo types.PayerInfo, totalFeeRd1 math.Int, hashId []byte) (math.Int, error) { + amtFixed6, remainder := CalculateRefundAmount(payerInfo.Amount, totalFeeRd1) coins := sdk.NewCoins(sdk.NewCoin(layertypes.BondDenom, amtFixed6)) if !payerInfo.FromBond { @@ -173,7 +177,10 @@ func (k Keeper) RewardReporterBondToFeePayers(ctx context.Context, feePayer sdk. return remainder, k.bankKeeper.SendCoinsFromModuleToModule(ctx, types.ModuleName, stakingtypes.BondedPoolName, sdk.NewCoins(sdk.NewCoin(layertypes.BondDenom, amtFixed6))) } -func (k Keeper) GetSumOfAllGroupVotesAllRounds(ctx context.Context, id uint64) (math.Int, error) { +// GetSumOfUserAndReporterVotesAllRounds sums claim-eligible (user and reporter) vote +// power across all rounds of a dispute. Team votes are excluded because they cannot +// claim voter rewards. +func (k Keeper) GetSumOfUserAndReporterVotesAllRounds(ctx context.Context, id uint64) (math.Int, error) { dispute, err := k.Disputes.Get(ctx, id) if err != nil { return math.Int{}, err @@ -181,19 +188,20 @@ func (k Keeper) GetSumOfAllGroupVotesAllRounds(ctx context.Context, id uint64) ( sumUsers := uint64(0) sumReporters := uint64(0) - sumTeam := uint64(0) // process vote counts function processVoteCounts := func(voteCounts types.StakeholderVoteCounts) { sumUsers += voteCounts.Users.Support + voteCounts.Users.Against + voteCounts.Users.Invalid sumReporters += voteCounts.Reporters.Support + voteCounts.Reporters.Against + voteCounts.Reporters.Invalid - sumTeam += voteCounts.Team.Support + voteCounts.Team.Against + voteCounts.Team.Invalid } // process current dispute voteCounts, err := k.VoteCountsByGroup.Get(ctx, id) if err != nil { - return math.ZeroInt(), nil + if !errors.Is(err, collections.ErrNotFound) { + return math.Int{}, err + } + voteCounts = types.StakeholderVoteCounts{} } processVoteCounts(voteCounts) @@ -201,18 +209,16 @@ func (k Keeper) GetSumOfAllGroupVotesAllRounds(ctx context.Context, id uint64) ( for _, roundId := range dispute.PrevDisputeIds { voteCounts, err := k.VoteCountsByGroup.Get(ctx, roundId) if err != nil { - voteCounts = types.StakeholderVoteCounts{ - Users: types.VoteCounts{Support: 0, Against: 0, Invalid: 0}, - Reporters: types.VoteCounts{Support: 0, Against: 0, Invalid: 0}, - Team: types.VoteCounts{Support: 0, Against: 0, Invalid: 0}, + if !errors.Is(err, collections.ErrNotFound) { + return math.Int{}, err } + voteCounts = types.StakeholderVoteCounts{} } processVoteCounts(voteCounts) } totalSum := math.NewInt(int64(sumUsers)). - Add(math.NewInt(int64(sumReporters))). - Add(math.NewInt(int64(sumTeam))) + Add(math.NewInt(int64(sumReporters))) return totalSum, nil } diff --git a/x/dispute/keeper/execute_test.go b/x/dispute/keeper/execute_test.go index cffb150af..22f26e32c 100644 --- a/x/dispute/keeper/execute_test.go +++ b/x/dispute/keeper/execute_test.go @@ -93,12 +93,12 @@ func (k *KeeperTestSuite) TestRefundDisputeFee() { k.reporterKeeper.On("FeeRefund", k.ctx, []byte("hash"), math.NewInt(760)).Return(nil) k.bankKeeper.On("SendCoinsFromModuleToModule", k.ctx, types.ModuleName, "bonded_tokens_pool", sdk.NewCoins(sdk.NewCoin("loya", math.NewInt(760)))).Return(nil) - dust, err := k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer1, feePayers[0], math.NewInt(1000), []byte("hash"), math.NewInt(1000)) + dust, err := k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer1, feePayers[0], math.NewInt(1000), []byte("hash")) k.NoError(err) k.True(math.ZeroInt().Equal(dust)) k.bankKeeper.On("SendCoinsFromModuleToAccount", k.ctx, types.ModuleName, feepayer2, sdk.NewCoins(sdk.NewCoin("loya", math.NewInt(190)))).Return(nil) - dust, err = k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer2, feePayers[1], math.NewInt(1000), []byte("hash"), math.NewInt(1000)) + dust, err = k.disputeKeeper.RefundDisputeFee(k.ctx, feepayer2, feePayers[1], math.NewInt(1000), []byte("hash")) k.NoError(err) k.True(math.ZeroInt().Equal(dust)) } @@ -155,23 +155,22 @@ func (k *KeeperTestSuite) TestRewardReporterBondToFeePayers() { k.Equal(shareFixed12.Mod(layertypes.PowerReduction), dust) } -func (k *KeeperTestSuite) TestGetSumOfAllGroupVotesAllRounds() { +func (k *KeeperTestSuite) TestGetSumOfUserAndReporterVotesAllRounds() { k.ctx = k.ctx.WithBlockTime(time.Now()) dispute := k.dispute(k.ctx) k.NoError(k.disputeKeeper.Disputes.Set(k.ctx, dispute.DisputeId, dispute)) - // set vote counts for current dispute + // set vote counts for current dispute; team votes are set but must not be counted currentVoteCounts := types.StakeholderVoteCounts{ Users: types.VoteCounts{Support: 10, Against: 5, Invalid: 2}, // 17 Reporters: types.VoteCounts{Support: 8, Against: 3, Invalid: 1}, // 12 - // Tokenholders: types.VoteCounts{Support: 15, Against: 7, Invalid: 3}, // 25 - Team: types.VoteCounts{Support: 5, Against: 2, Invalid: 1}, // 8 total=37 + Team: types.VoteCounts{Support: 5, Against: 2, Invalid: 1}, // excluded, total=29 } k.NoError(k.disputeKeeper.VoteCountsByGroup.Set(k.ctx, dispute.DisputeId, currentVoteCounts)) // test no previous disputes - expectedTotalSum := math.NewInt(37) - totalSum, err := k.disputeKeeper.GetSumOfAllGroupVotesAllRounds(k.ctx, dispute.DisputeId) + expectedTotalSum := math.NewInt(29) + totalSum, err := k.disputeKeeper.GetSumOfUserAndReporterVotesAllRounds(k.ctx, dispute.DisputeId) k.NoError(err) k.True(expectedTotalSum.Equal(totalSum)) @@ -181,20 +180,17 @@ func (k *KeeperTestSuite) TestGetSumOfAllGroupVotesAllRounds() { { Users: types.VoteCounts{Support: 5, Against: 3, Invalid: 1}, // 9 Reporters: types.VoteCounts{Support: 4, Against: 2, Invalid: 0}, // 6 - // Tokenholders: types.VoteCounts{Support: 8, Against: 4, Invalid: 2}, // 14 - Team: types.VoteCounts{Support: 3, Against: 1, Invalid: 0}, // 4 total=19 + Team: types.VoteCounts{Support: 3, Against: 1, Invalid: 0}, // excluded, total=15 }, { Users: types.VoteCounts{Support: 7, Against: 4, Invalid: 2}, // 13 Reporters: types.VoteCounts{Support: 6, Against: 3, Invalid: 1}, // 10 - // Tokenholders: types.VoteCounts{Support: 10, Against: 5, Invalid: 2}, // 17 - Team: types.VoteCounts{Support: 4, Against: 2, Invalid: 1}, // 7 total=30 + Team: types.VoteCounts{Support: 4, Against: 2, Invalid: 1}, // excluded, total=23 }, { Users: types.VoteCounts{Support: 3, Against: 2, Invalid: 0}, // 5 Reporters: types.VoteCounts{Support: 2, Against: 1, Invalid: 0}, // 3 - // Tokenholders: types.VoteCounts{Support: 5, Against: 3, Invalid: 1}, // 9 - Team: types.VoteCounts{Support: 2, Against: 1, Invalid: 0}, // 3 total=11 + Team: types.VoteCounts{Support: 2, Against: 1, Invalid: 0}, // excluded, total=8 }, } @@ -205,15 +201,15 @@ func (k *KeeperTestSuite) TestGetSumOfAllGroupVotesAllRounds() { k.NoError(k.disputeKeeper.Disputes.Set(k.ctx, dispute.DisputeId, dispute)) - // Calculate the expected total sum + // Calculate the expected total sum (team votes excluded) expectedTotalSum = math.NewInt(0). - Add(math.NewInt(int64(17 + 12 + 8))). // Current dispute - Add(math.NewInt(int64(9 + 6 + 4))). // Previous dispute 1 - Add(math.NewInt(int64(13 + 10 + 7))). // Previous dispute 2 - Add(math.NewInt(int64(5 + 3 + 3))) // Previous dispute 3 + Add(math.NewInt(int64(17 + 12))). // Current dispute + Add(math.NewInt(int64(9 + 6))). // Previous dispute 1 + Add(math.NewInt(int64(13 + 10))). // Previous dispute 2 + Add(math.NewInt(int64(5 + 3))) // Previous dispute 3 // Call the function and check the result - totalSum, err = k.disputeKeeper.GetSumOfAllGroupVotesAllRounds(k.ctx, dispute.DisputeId) + totalSum, err = k.disputeKeeper.GetSumOfUserAndReporterVotesAllRounds(k.ctx, dispute.DisputeId) k.NoError(err) k.True(expectedTotalSum.Equal(totalSum)) } diff --git a/x/dispute/keeper/math_utils.go b/x/dispute/keeper/math_utils.go index f849df03b..d3e345c7d 100644 --- a/x/dispute/keeper/math_utils.go +++ b/x/dispute/keeper/math_utils.go @@ -8,20 +8,20 @@ import ( // CalculateRefundAmount calculates the amount of the fee to be refunded to the payer // returns the amount to be refunded (amtFixed6) and the remainder (dust) -func CalculateRefundAmount(payerFee, totalFeeRd1, disputeFeeTotal math.Int) (math.Int, math.Int) { +func CalculateRefundAmount(payerFee, totalFeeRd1 math.Int) (math.Int, math.Int) { payerFeeDec := payerFee.ToLegacyDec() totalFeeRd1Dec := math.LegacyNewDecFromInt(totalFeeRd1) - // fivePercent = disputeFeeTotal / 20 - fivePercentDec := disputeFeeTotal.ToLegacyDec().Quo(math.LegacyNewDec(20)) + // fivePercent = totalFeeRd1 / 20 + fivePercentDec := totalFeeRd1.ToLegacyDec().Quo(math.LegacyNewDec(20)) fivePercent := fivePercentDec.TruncateInt() - // totalFeeMinusBurn = disputeFeeTotal - fivePercent - totalFeeMinusBurnDec := disputeFeeTotal.Sub(fivePercent).ToLegacyDec() + // totalFeeMinusBurn = totalFeeRd1 - fivePercent + totalFeeMinusBurnDec := totalFeeRd1.Sub(fivePercent).ToLegacyDec() powerReductionDec := math.LegacyNewDecFromInt(layertypes.PowerReduction) - // (fee paid in rd1 / total fee rd 1) * (total fee all rounds - burn) + // (payerFee / totalFeeRd1) * (totalFeeRd1 - burn) // result scaled by PowerReduction amtFixed12Dec := payerFeeDec.Mul(totalFeeMinusBurnDec).Mul(powerReductionDec).Quo(totalFeeRd1Dec) diff --git a/x/dispute/keeper/math_utils_test.go b/x/dispute/keeper/math_utils_test.go index 8914ca3cd..f69c92b7a 100644 --- a/x/dispute/keeper/math_utils_test.go +++ b/x/dispute/keeper/math_utils_test.go @@ -12,54 +12,48 @@ import ( func TestCalculateRefundAmount(t *testing.T) { tests := []struct { - name string - payerFee math.Int - totalFeeRd1 math.Int - disputeFeeTotal math.Int - expectedAmt math.Int - expectedDust math.Int + name string + payerFee math.Int + totalFeeRd1 math.Int + expectedAmt math.Int + expectedDust math.Int }{ { - name: "full refund, single payer", - payerFee: math.NewInt(1000), - totalFeeRd1: math.NewInt(1000), - disputeFeeTotal: math.NewInt(1000), + name: "full refund, single payer", + payerFee: math.NewInt(1000), + totalFeeRd1: math.NewInt(1000), // Pot = 1000 - 50 = 950 // Share = 1.0 * 950 = 950 expectedAmt: math.NewInt(950), expectedDust: math.ZeroInt(), }, { - name: "2 even payers", - payerFee: math.NewInt(500), - totalFeeRd1: math.NewInt(1000), - disputeFeeTotal: math.NewInt(1000), + name: "2 even payers", + payerFee: math.NewInt(500), + totalFeeRd1: math.NewInt(1000), // 95% of (500/1000) of 1000 = 475 expectedAmt: math.NewInt(475), expectedDust: math.ZeroInt(), }, { - name: "zero fee", - payerFee: math.ZeroInt(), - totalFeeRd1: math.NewInt(100), - disputeFeeTotal: math.NewInt(100), - expectedAmt: math.ZeroInt(), - expectedDust: math.ZeroInt(), + name: "zero fee", + payerFee: math.ZeroInt(), + totalFeeRd1: math.NewInt(100), + expectedAmt: math.ZeroInt(), + expectedDust: math.ZeroInt(), }, { - name: "large numbers", - payerFee: math.NewInt(123456789000000), // 123,456,789 trb - totalFeeRd1: math.NewInt(987654321000000), // 987,654,321 trb - disputeFeeTotal: math.NewInt(987654321000000), // 987,654,321 trb + name: "large numbers", + payerFee: math.NewInt(123456789000000), // 123,456,789 trb + totalFeeRd1: math.NewInt(987654321000000), // 987,654,321 trb // 95% of 12.5% of 987654321000000 = 117283949550000 expectedAmt: math.NewInt(117283949550000), expectedDust: math.ZeroInt(), }, { - name: "dust", - payerFee: math.NewInt(333), - totalFeeRd1: math.NewInt(1000), - disputeFeeTotal: math.NewInt(1000), + name: "dust", + payerFee: math.NewInt(333), + totalFeeRd1: math.NewInt(1000), // 95% of (333/1000) of 1000 = 316.35 expectedAmt: math.NewInt(316), expectedDust: math.NewInt(350000), // 0.35 loya * 1e6 @@ -69,7 +63,7 @@ func TestCalculateRefundAmount(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fmt.Println(tc.name) - amt, dust := keeper.CalculateRefundAmount(tc.payerFee, tc.totalFeeRd1, tc.disputeFeeTotal) + amt, dust := keeper.CalculateRefundAmount(tc.payerFee, tc.totalFeeRd1) require.True(t, tc.expectedAmt.Equal(amt), "expected amount %s, got %s", tc.expectedAmt, amt) require.True(t, tc.expectedDust.Equal(dust), "expected dust %s, got %s", tc.expectedDust, dust) }) diff --git a/x/dispute/keeper/msg_server_withdraw_fee_refund.go b/x/dispute/keeper/msg_server_withdraw_fee_refund.go index ab5ffe0e7..a2b2c6a95 100644 --- a/x/dispute/keeper/msg_server_withdraw_fee_refund.go +++ b/x/dispute/keeper/msg_server_withdraw_fee_refund.go @@ -32,10 +32,8 @@ func (k msgServer) WithdrawFeeRefund(ctx context.Context, msg *types.MsgWithdraw // get previous disputes prevDisputes := dispute.PrevDisputeIds - var firstRoundDisputeId uint64 - if len(prevDisputes) == 0 { - firstRoundDisputeId = msg.Id - } else { + firstRoundDisputeId := msg.Id + if len(prevDisputes) > 0 { firstRoundDisputeId = prevDisputes[0] } @@ -68,20 +66,20 @@ func (k msgServer) WithdrawFeeRefund(ctx context.Context, msg *types.MsgWithdraw switch vote.VoteResult { case types.VoteResult_INVALID, types.VoteResult_NO_QUORUM_MAJORITY_INVALID: - fraction, err := k.RefundDisputeFee(ctx, feePayer, payerInfo, dispute.FeeTotal, dispute.HashId, dispute.SlashAmount) + fraction, err := k.RefundDisputeFee(ctx, feePayer, payerInfo, dispute.SlashAmount, dispute.HashId) if err != nil { return nil, err } remainder = remainder.Add(fraction) case types.VoteResult_SUPPORT, types.VoteResult_NO_QUORUM_MAJORITY_SUPPORT: - fraction, err := k.RefundDisputeFee(ctx, feePayer, payerInfo, dispute.FeeTotal, dispute.HashId, dispute.SlashAmount) + fraction, err := k.RefundDisputeFee(ctx, feePayer, payerInfo, dispute.SlashAmount, dispute.HashId) if err != nil { return nil, err } remainder = remainder.Add(fraction) - fraction, err = k.RewardReporterBondToFeePayers(ctx, feePayer, payerInfo, dispute.FeeTotal, dispute.SlashAmount) + fraction, err = k.RewardReporterBondToFeePayers(ctx, feePayer, payerInfo, dispute.SlashAmount, dispute.SlashAmount) if err != nil { return nil, err } diff --git a/x/dispute/keeper/query.go b/x/dispute/keeper/query.go index ca73c12d0..35a52da40 100644 --- a/x/dispute/keeper/query.go +++ b/x/dispute/keeper/query.go @@ -386,25 +386,31 @@ func (k Querier) ClaimableDisputeRewards(ctx context.Context, req *types.QueryCl // Calculate Voter Reward if dispute.DisputeStatus == types.Resolved { - // Check if they voted + // Mirror ClaimReward: the final-round Voter record only tracks claim status + // (ClaimReward stores RewardClaimed under the final round id), while + // CalculateReward scans every round, so an address that voted only in a + // previous round is still eligible without a final-round Voter record voterInfo, err := k.Keeper.Voter.Get(ctx, collections.Join(req.DisputeId, addr.Bytes())) if err == nil { - // Found voter info rewardClaimed = voterInfo.RewardClaimed - if !voterInfo.RewardClaimed { - // They voted and haven't claimed yet - // CalculateReward checks if vote.Executed and other conditions - reward, err := k.Keeper.CalculateReward(sdkCtx, addr, req.DisputeId) - if err == nil { - rewardAmount = reward - } + } + if !rewardClaimed { + // CalculateReward checks vote.Executed and returns zero for non-voters + reward, err := k.Keeper.CalculateReward(sdkCtx, addr, req.DisputeId) + if err == nil { + rewardAmount = reward } } } // Calculate Fee Refund - // Check if they are a fee payer for the first round - payerInfo, err := k.Keeper.DisputeFeePayer.Get(ctx, collections.Join(req.DisputeId, addr.Bytes())) + // Fee payer records are stored under the first-round dispute id only; resolve it the + // same way WithdrawFeeRefund does so queries for a final round still find the payer + firstRoundDisputeId := req.DisputeId + if len(dispute.PrevDisputeIds) > 0 { + firstRoundDisputeId = dispute.PrevDisputeIds[0] + } + payerInfo, err := k.Keeper.DisputeFeePayer.Get(ctx, collections.Join(firstRoundDisputeId, addr.Bytes())) if err == nil { // Address is a fee payer switch dispute.DisputeStatus { @@ -416,14 +422,14 @@ func (k Querier) ClaimableDisputeRewards(ctx context.Context, req *types.QueryCl if err == nil && vote.Executed { switch vote.VoteResult { case types.VoteResult_INVALID, types.VoteResult_NO_QUORUM_MAJORITY_INVALID: - refund, _ := CalculateRefundAmount(payerInfo.Amount, dispute.SlashAmount, dispute.FeeTotal) + refund, _ := CalculateRefundAmount(payerInfo.Amount, dispute.SlashAmount) feeRefundAmount = feeRefundAmount.Add(refund) case types.VoteResult_SUPPORT, types.VoteResult_NO_QUORUM_MAJORITY_SUPPORT: - refund, _ := CalculateRefundAmount(payerInfo.Amount, dispute.SlashAmount, dispute.FeeTotal) + refund, _ := CalculateRefundAmount(payerInfo.Amount, dispute.SlashAmount) feeRefundAmount = feeRefundAmount.Add(refund) - reward, _ := CalculateReporterBondRewardAmount(payerInfo.Amount, dispute.FeeTotal, dispute.SlashAmount) + reward, _ := CalculateReporterBondRewardAmount(payerInfo.Amount, dispute.SlashAmount, dispute.SlashAmount) feeRefundAmount = feeRefundAmount.Add(reward) } } diff --git a/x/dispute/keeper/query_test.go b/x/dispute/keeper/query_test.go index c030b8429..4c4a247c6 100644 --- a/x/dispute/keeper/query_test.go +++ b/x/dispute/keeper/query_test.go @@ -695,8 +695,9 @@ func (s *KeeperTestSuite) TestClaimableDisputeRewardsQuery() { RewardClaimed: false, })) - // Setup Fee Payer for Dispute 5 - require.NoError(k.DisputeFeePayer.Set(ctx, collections.Join(uint64(5), addr.Bytes()), types.PayerInfo{ + // Setup Fee Payer under Dispute 5's first round (dispute 1) — payer records are only + // ever stored under the round-1 dispute id + require.NoError(k.DisputeFeePayer.Set(ctx, collections.Join(uint64(1), addr.Bytes()), types.PayerInfo{ Amount: math.NewInt(500), }))