diff --git a/README.md b/README.md index aaaeb43..4a97bed 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ To familiarize yourself with the sortition pool and it's design, we provide + [Building Intuition](docs/building-intuition.md) + [Implementation Details](docs/implementation-details.md) ++ [Rewards](docs/rewards.md) [Building Intuition](docs/building-intuition.md) starts the reader from the problem description and an easy-to-understand naive solution, and then works @@ -26,6 +27,10 @@ the finer points about the data structure, data (de)serialization, how operators join/leave the pool, and how it all comes together to select a full group. +[Rewards](docs/rewards.md) is a deep-dive into how the sortition pool keeps +track of rewards. It features code explanations and walk-throughs of state +transitions for common situations. + ## Important Facts + The max number of operators is `2,097,152` @@ -34,6 +39,7 @@ group. + The sortition pool can be [optimistic](#optimisic-group-selection)! The on-chain code then is only run in the case that the selection submission is challenged. ++ The sortition pool tracks rewards! ## Safe Use diff --git a/docs/rewards.md b/docs/rewards.md new file mode 100644 index 0000000..521bdba --- /dev/null +++ b/docs/rewards.md @@ -0,0 +1,429 @@ +### Rewards + +The rewards implementation is a weight-based pool. When the pool receives +rewards, an operator's share of those rewards is equal to their share of the +pool. + +The pool provides 3 basic functions: + ++ updating an operator's weight in the pool ++ granting the pool rewards ++ withdrawing an operator's rewards + +On top of those basic functions, higher level concepts can be constructed. For +example, joining the pool is implemented as updating an operator's weight from +0 to positive. Leaving the pool is implemented as updating the operator's +weight from positive to 0. + +In order to accomplish these main functions, we create 3 pieces of state: + +``` +struct OperatorRewards { + uint96 accumulated; + uint96 available; + uint32 weight; +} +uint96 internal globalRewardAccumulator; // (1) +uint96 internal rewardRoundingDust; // (2) +mapping(uint32 => OperatorRewards) internal operatorRewards; // (3) +``` + +The (1) `globalRewardAccumulator` represents how much reward a 1-`weight` +operator would have accumulated since genesis. Since we're working in integers, +and since rewards won't always be cleanly divisible by the pool weight, there +carry-over, which is stored in (2) `rewardRoundingDust`. + +Finally, the (3) `operatorRewards` keep track of each operator's individual +state indexed by their `id`. An operator's `accumulated` value represents a +snapshot of the `globalRewardAccumulator` at the time they were last updated. +Their `available` value represents how much reward is available for withdraw, +as of their most recent update. Their `weight` is their weight in the pool. + +To see how all of these pieces of state interact, we can go through some +event logs. + +#### Join -> Reward -> Withdraw +``` +event 1) Alice (id 1) joins the pool with weight 10 +event 2) 123 rewards are granted to the pool +event 3) Alice withdraws their rewards +``` + +We start at a fresh state: +``` +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +operatorRewards: {} +``` + +Joining the pool is handled with `updateOperatorRewards(1, 10)` (some +complexity abridged to be introduced later) +``` +function updateOperatorRewards(uint32 operator, uint32 newWeight) internal { + uint96 acc = globalRewardAccumulator; + OperatorRewards memory o = operatorRewards[operator]; + uint96 accruedRewards = (acc - o.accumulated) * uint96(o.weight); + o.available += accruedRewards; + o.accumulated = acc; + o.weight = newWeight; + operatorRewards[operator] = o; +} +``` + +Following the math, we get: +``` +acc = 0 +o = {accumulated: 0, available: 0, weight: 0} +accruedRewards = (0 - 0) * 0 = 0 +o.available = 0 + 0 = 0 +o.accumulated = 0 +o.weight = 10 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}} + +// new state +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}} +``` + +Ever time an operator is updated, they accrue rewards equal to however much the +`globalRewardAccumulator` accrued in between now and the last time they were +updated, multiplied by their weight (since the acccumulator has a weight of 1). +We use that to inform how many rewards are available to them and then update +their snapshot of the accumulator state and their weight. + +Next, 123 rewards are granted to the pool. We call `addRewards(123, 10)` + +``` +function addRewards(uint96 rewardAmount, uint32 currentPoolWeight) internal { + require(currentPoolWeight >= 0, "No recipients in pool"); + + uint96 totalAmount = rewardAmount + rewardRoundingDust; + uint96 perWeightReward = totalAmount / currentPoolWeight; + uint96 newRoundingDust = totalAmount % currentPoolWeight; + + globalRewardAccumulator += perWeightReward; + rewardRoundingDust = newRoundingDust; +} +``` + +Following the math, we get: +``` +totalAmount = 123 + 0 = 123 +perWeightReward = 123 / 10 = 12 +newRoundingDust = 123 % 10 = 3 +globalRewardAccumulator = 0 + 12 = 12 +rewardRoundingDust = 3 + +// new state +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}} +``` + +Whenever rewards are distributed, we *only* update `globalRewardAccumulator` +and `rewardRoundingDust`, never any of the operators. Those are only updated +lazily via `updateOperatorRewards`. Note that at this point, alice's +accumulator is 12 rewards behind the global accumulator! + +In order to Alice to withdraw her rewards, she shoud *update* herself first, +since her `available` state is `0`. So, first we call `updateOperatorRewards(1, 10)` + +``` +acc = 12 +o = {accumulated: 0, available: 0, weight: 10} +accruedRewards = (12 - 0) * 10 = 120 +o.available = 0 + 120 = 120 +o.accumulated = 12 +o.weight = 10 +operatorRewards: {1: {accumulated: 12, available: 120, weight: 10}} + +// new state +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: {1: {accumulated: 12, available: 120, weight: 10}} +``` + +Some amount of reward (an amount between 0 and the total operator weight) will +be unavailable for withdraws due to how the `rewardRoundingDust` works. In +threshold, the weight precision is 1 weight = 1 T, (1e18 divisor), but rewards +are represented with full precision (1e18), so numerically, even small rewards +should greatly exceed the total pool weight. + +Alice is now ready to withdraw! We call `withdrawOperatorRewards(1)` + +``` +function withdrawOperatorRewards(uint32 operator) + internal + returns (uint96 withdrawable) +{ + OperatorRewards storage o = operatorRewards[operator]; + withdrawable = o.available; + o.available = 0; +} + +// new state +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: {1: {accumulated: 12, available: 0, weight: 10}} +``` + +This returns `120` to the caller, and sets Alice's available rewards to 0. The +`Rewards.sol` code isn't responsible for handling any token transaction, only +keeping track of the rewards state. That `120` amount is handled by the +`SortitionPool`, to send Alice the appropriate amount of tokens. + +State + Event Logs: +``` +event 1) pool is created +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +operatorRewards: {} + +event 2) Alice (id 1) joins the pool with weight 10 +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}} + +event 3) 123 rewards are granted to the pool +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}} + +event 4) Update Alice +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: {1: {accumulated: 12, available: 120, weight: 10}} + +event 5) Withdraw Alice's Rewards +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: {1: {accumulated: 12, available: 0, weight: 10}} +return: 120 +``` + +#### Two Operators With Offset Rewards +State + Event Logs: +``` +event 1) pool is created +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +operatorRewards: {} + +event 2) Alice (id 1) joins the pool with weight 10 +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}} + +event 3) 123 rewards are granted to the pool +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10}} + +event 4) Bob (id 2) joins the pool with weight 20 +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +operatorRewards: { + 1: {accumulated: 0, available: 0, weight: 10} + 2: {accumulated: 12, available: 0, weight: 20} +} + +event 5) 321 rewards are granted to the pool +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +operatorRewards: { + 1: {accumulated: 0, available: 0, weight: 10} + 2: {accumulated: 12, available: 0, weight: 20} +} + +event 6) Update Alice +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +operatorRewards: { + 1: {accumulated: 22, available: 220, weight: 10} + 2: {accumulated: 12, available: 0, weight: 20} +} + +event 7) Withdraw Alice's rewards +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +operatorRewards: { + 1: {accumulated: 22, available: 0, weight: 10} + 2: {accumulated: 12, available: 0, weight: 20} +} +return: 220 + +event 8) Update Bob +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +operatorRewards: { + 1: {accumulated: 22, available: 0, weight: 10} + 2: {accumulated: 22, available: 200, weight: 20} +} + +event 9) Withdraw Bob's Rewards +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +operatorRewards: { + 1: {accumulated: 22, available: 0, weight: 10} + 2: {accumulated: 22, available: 0, weight: 20} +} +return 200 +``` + +In theory, Alice should be given 100% of the first `123` reward, and then 1/3 +of the `321` reward coming out to a total of 230, which is 51.8% of the +rewards. Bob should only receive 2/3 of the 321 reward coming to a total of +214, which is 48.2%. Since the reward amounts are close to the weight amounts, +the dust is significant enough to be impactful here. + +A total of 420 rewards were withdrawn by Alice and Bob (the other 24 are +marooned in `rewardRoundingDust`). The higher the reward amount is relative to +the weight amount, the less this is significant. + +#### Eligibility + +In addition to the above functions, we want to be mark operators as +"ineligable" for rewards, temporarily. In practice, if Alice and Bob both have +10 weight in the pool and 100 rewards are added while Bob is ineligable, Alice +gets 50, Bob gets 0, and the pool owner is able to retrieve those 50 that Bob +would have gotten at a later date. + +In order to accomplish this, we need two more pieces of state: + +``` +uint96 public ineligibleEarnedRewards; +struct OperatorRewards { + uint32 ineligibleUntil; +} +``` + +We keep track of the total amount of reward that operators accumulated while +they were ineligable, and each operator keeps track of when they're allowed to +be eligible again. If `ineligibleUntil == 0`, the operator is eligible. + +That means that `updateOperatorRewards` gets a modification: +``` +function updateOperatorRewards(uint32 operator, uint32 newWeight) internal { + uint96 acc = globalRewardAccumulator; + OperatorRewards memory o = operatorRewards[operator]; + uint96 accruedRewards = (acc - o.accumulated) * uint96(o.weight); + if (o.ineligibleUntil == 0) { + o.available += accruedRewards; + } else { + ineligibleEarnedRewards += accruedRewards; + } + o.accumulated = acc; + o.weight = newWeight; + operatorRewards[operator] = o; +} +``` + +We replaced `o.available += accruedRewards;` with +``` + if (o.ineligibleUntil == 0) { + o.available += accruedRewards; + } else { + ineligibleEarnedRewards += accruedRewards; + } +``` +If the operator is eligible, they accrue rewards, otherwise, +`ineligibleEarnedRewards` accrues those rewards instead. + +Finally, the contract owner needs a way to extract ineligable rewards: +``` +function withdrawIneligibleRewards() internal returns (uint96 withdrawable) { + withdrawable = ineligibleEarnedRewards; + ineligibleEarnedRewards = 0; +} +``` + +The rest is managing eligibility properly - methods that set and restore a +operator's eligibility, and to make sure that doing so properly updates the +operator's state. + +State + Event Logs +``` +event 1) pool is created +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +ineligibleEarnedRewards: 0 +operatorRewards: {} + +event 2) Alice (id 1) joins the pool with weight 10 +globalRewardAccumulator: 0 +rewardRoundingDust: 0 +ineligibleEarnedRewards: 0 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0}} + +event 3) 123 rewards are granted to the pool +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +ineligibleEarnedRewards: 0 +operatorRewards: {1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0}} + +event 4) Bob (id 2) joins the pool with weight 20 +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +ineligibleEarnedRewards: 0 +operatorRewards: { + 1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0} + 2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: 0} +} + +event 5) Bob is set as ineligable until 100000 seconds from now +globalRewardAccumulator: 12 +rewardRoundingDust: 3 +ineligibleEarnedRewards: 0 +operatorRewards: { + 1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0} + 2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000} +} + +event 6) 321 rewards are granted to the pool +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +ineligibleEarnedRewards: 0 +operatorRewards: { + 1: {accumulated: 0, available: 0, weight: 10, ineligibleUntil: 0} + 2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000} +} + +event 7) Update Alice +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +ineligibleEarnedRewards: 0 +operatorRewards: { + 1: {accumulated: 22, available: 220, weight: 10, ineligibleUntil: 0} + 2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000} +} + +event 8) Withdraw Alice's rewards +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +ineligibleEarnedRewards: 0 +operatorRewards: { + 1: {accumulated: 22, available: 0, weight: 10, ineligibleUntil: 0} + 2: {accumulated: 12, available: 0, weight: 20, ineligibleUntil: event5-time + 10000} +} +return: 220 + +event 9) Update Bob +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +ineligibleEarnedRewards: 200 +operatorRewards: { + 1: {accumulated: 22, available: 0, weight: 10, ineligibleUntil: 0} + 2: {accumulated: 22, available: 0, weight: 20, ineligibleUntil: event5-time + 10000} +} + +event 10) Withdraw Ineligable Rewards +globalRewardAccumulator: 22 +rewardRoundingDust: 24 +ineligibleEarnedRewards: 0 +operatorRewards: { + 1: {accumulated: 22, available: 0, weight: 10, ineligibleUntil: 0} + 2: {accumulated: 22, available: 0, weight: 20, ineligibleUntil: event5-time + 10000} +} +return: 200 +``` diff --git a/test/rewardsTest.js b/test/rewardsTest.js index 3e708f3..87b2ca3 100644 --- a/test/rewardsTest.js +++ b/test/rewardsTest.js @@ -3,17 +3,13 @@ const expect = chai.expect const { ethers, helpers } = require("hardhat") describe("Rewards", () => { - let alice - let bob - let carol + const alice = 1 + const bob = 2 + const carol = 3 let rewards beforeEach(async () => { - alice = 1 - bob = 2 - carol = 3 - const RewardsStub = await ethers.getContractFactory("RewardsStub") rewards = await RewardsStub.deploy() await rewards.deployed() @@ -35,7 +31,11 @@ describe("Rewards", () => { await rewards.withdrawRewards(bob) const bobRewards = await rewards.getWithdrawnRewards(bob) + // Since alice makes up 10 / 100 = 10% of the pool weight, alice expects + // 10% of the rewards. 10% of 1000 is 100. expect(aliceRewards).to.be.equal(100) + // Since bob makes up 90 / 100 = 90% of the pool weight, bob expects 90% + // of the rewards. 90% of 1000 is 900. expect(bobRewards).to.be.equal(900) }) @@ -52,7 +52,13 @@ describe("Rewards", () => { await rewards.withdrawRewards(bob) const bobRewards = await rewards.getWithdrawnRewards(bob) + // Since alice made up 10 / 10 = 100% of the pool weight (as bob's weight + // was updated to 0 before rewards were paid), alice expects 100% of + // the rewards. 100% of 1000 is 1000. expect(aliceRewards).to.equal(1000) + // Since bob made up 0 / 10 = 0% of the pool weight (as bob's weight was + // updated to 0 before rewards were paid), bob expects 0% of the + // rewards. 0% of 1000 is 0. expect(bobRewards).to.equal(0) }) @@ -61,6 +67,13 @@ describe("Rewards", () => { await rewards.payReward(123) + // The way that reward state is tracked is through a global accumulator + // that simulates what a hypothetical 1-weight operator would receive in + // rewards for each payout, accompanied by roundingDust variable that + // stores integer division remainders. For this example, the pool has 10 + // weight, and a reward of 123 was just granted, so a 1-weight operator + // would receive 12 rewards with 3 left over (12 * 10 + 3 = 123). The + // remainder is added to the next reward. const acc1 = await rewards.getGlobalAccumulator() expect(acc1).to.equal(12) @@ -71,6 +84,10 @@ describe("Rewards", () => { await rewards.payReward(987) + // We previously had a remainder of 3, and just received 987 rewards for + // a total of 990. The total pool weight is 30, so our 1-weight operator + // receives 990 / 30 = 33 rewards with 0 left over. That 33 is added to + // the previous 12 to get to 45. const acc2 = await rewards.getGlobalAccumulator() expect(acc2).to.equal(45) @@ -106,9 +123,12 @@ describe("Rewards", () => { const bobAcc1 = await rewards.getAccumulator(bob) expect(bobAcc1).to.equal(10) + // Accrued rewards only change when an operator is updated. const aliceRew1 = await rewards.getAccruedRewards(alice) expect(aliceRew1).to.equal(0) + // An operator's accumulator state only changes when an operator is + // updated. const aliceAcc1 = await rewards.getAccumulator(alice) expect(aliceAcc1).to.equal(0) @@ -225,12 +245,16 @@ describe("Rewards", () => { // Alice: 10; Bob: 90 await rewards.payReward(100) + // Make Bob ineligible for 10 seconds await rewards.makeIneligible(bob, 10) // Reward only to Alice // Alice: 20; Bob: 90; Ineligible: 90 await rewards.payReward(100) + // Ineligibility is set for a duration. Bob was ineligible for 10 + // seconds, so we move forward 11 seconds to allow us to make him + // eligible again. await helpers.time.increaseTime(11) await rewards.makeEligible(bob) diff --git a/test/sortitionTreeTest.js b/test/sortitionTreeTest.js index e18c63b..34bd66e 100644 --- a/test/sortitionTreeTest.js +++ b/test/sortitionTreeTest.js @@ -19,48 +19,79 @@ describe("SortitionTree", () => { describe("setLeaf", async () => { context("when one leaf is set", () => { - beforeEach(async () => { - const weight1 = 0x1234 - const position1 = parseInt("00123456", 8) + it("should return correct value for the tree with a leaf in the first slot", async () => { + const weight = 0x1234 + const position = 42798 - const leaf = await sortition.toLeaf(alice.address, weight1) - await sortition.publicSetLeaf(position1, leaf, weight1) - }) - - it("should return correct value for the tree", async () => { + const leaf = await sortition.toLeaf(alice.address, weight) + await sortition.publicSetLeaf(position, leaf, weight) const root = await sortition.getRoot() + // + // Since the only leaf in the tree is the one we set, that's the only + // weight that propagates to the root node. The first slot in the root + // covers the sum of the first 8^6 = 262144 leaves. The next slot in + // the root covers the sum of the next 262144, and so on. expect(ethers.utils.hexlify(root)).to.be.equal("0x1234") + // The full output here looks like + // 0x00000000,00000000,00000000,00000000,00000000,00000000,00000000,00001234 + // slot 7 slot 6 slot 5 slot 4 slot 3 slot 2 slot 1 slot 0 + // without the commas added for readability. All the padding zeros are + // dropped when we hexlify. }) }) + it("should return correct value for the tree with a leaf in a second slot", async () => { + const weight = 0x1234 + const position = 262145 + + const leaf = await sortition.toLeaf(alice.address, weight) + await sortition.publicSetLeaf(position, leaf, weight) + const root = await sortition.getRoot() + // + // Since the only leaf in the tree is the one we set, that's the only + // weight that propagates to the root node. The first slot in the root + // covers the sum of the first 8^6 = 262144 leaves. The next slot in + // the root covers the sum of the next 262144, and so on. + expect(ethers.utils.hexlify(root)).to.be.equal("0x123400000000") + // The full output here looks like + // 0x00000000,00000000,00000000,00000000,00000000,00000000,00001234,00000000 + // slot 7 slot 6 slot 5 slot 4 slot 3 slot 2 slot 1 slot 0 + // without the commas added for readability. All the padding zeros are + // dropped when we hexlify, which simplifies to 0x123400000000. + }) + context("when two leaves are set", () => { - beforeEach(async () => { + it("should return correct value for the tree", async () => { const weight1 = 0x1234 - const position1 = parseInt("00123456", 8) + const position1 = 42798 + const weight2 = 0x11 - const position2 = parseInt("01234567", 8) + const position2 = 342391 const leaf1 = await sortition.toLeaf(alice.address, weight1) await sortition.publicSetLeaf(position1, leaf1, weight1) const leaf2 = await sortition.toLeaf(bob.address, weight2) await sortition.publicSetLeaf(position2, leaf2, weight2) - }) - - it("should return correct value for the tree", async () => { const root = await sortition.getRoot() expect(ethers.utils.hexlify(root)).to.be.equal("0x1100001234") + // The full output here looks like + // 0x00000000,00000000,00000000,00000000,00000000,00000000,00000011,00001234 + // slot 7 slot 6 slot 5 slot 4 slot 3 slot 2 slot 1 slot 0 + // without the commas added for readability. All the padding zeros are + // dropped when we hexlify, which simplifies to 0x1100001234. }) }) }) describe("removeLeaf", () => { context("when leaf is removed", () => { - beforeEach(async () => { + it("should return correct value for the tree", async () => { const weight1 = 0x1234 - const position1 = parseInt("00123456", 8) + const position1 = 42798 + const weight2 = 0x11 - const position2 = parseInt("01234567", 8) + const position2 = 342391 const leaf1 = await sortition.toLeaf(alice.address, weight1) await sortition.publicSetLeaf(position1, leaf1, weight1) @@ -68,11 +99,13 @@ describe("SortitionTree", () => { const leaf2 = await sortition.toLeaf(bob.address, weight2) await sortition.publicSetLeaf(position2, leaf2, weight2) await sortition.publicRemoveLeaf(position1) - }) - - it("should return correct value for the tree", async () => { const root = await sortition.getRoot() expect(ethers.utils.hexlify(root)).to.be.equal("0x1100000000") + // The full output here looks like + // 0x00000000,00000000,00000000,00000000,00000000,00000000,00000011,00000000 + // slot 7 slot 6 slot 5 slot 4 slot 3 slot 2 slot 1 slot 0 + // without the commas added for readability. All the padding zeros are + // dropped when we hexlify, which simplifies to 0x1100000000. }) }) }) @@ -80,25 +113,28 @@ describe("SortitionTree", () => { describe("insertOperator", () => { const weightA = 0xfff0 const weightB = 0x10000001 + // weightA + weightB = 0x1000fff1 context("when operators are inserted", () => { - beforeEach(async () => { + it("should return correct value for the tree", async () => { + // insertion begins left to right, so alice is inserted at position 0, + // and bob is inserted at position 1. Their weights will propagate to + // the root's first slot. await sortition.publicInsertOperator(alice.address, weightA) await sortition.publicInsertOperator(bob.address, weightB) - }) - - it("should return correct value for the tree", async () => { const root = await sortition.getRoot() - expect(ethers.utils.hexlify(root)).to.be.equal("0x1000fff1") + expect(ethers.utils.hexlify(root)).to.be.equal("0x1000fff1") // weightA + weightB + // The full output here looks like + // 0x00000000,00000000,00000000,00000000,00000000,00000000,00000000,1000fff1 + // slot 7 slot 6 slot 5 slot 4 slot 3 slot 2 slot 1 slot 0 + // without the commas added for readability. All the padding zeros are + // dropped when we hexlify, which simplifies to 0x1000fff1. }) }) context("when operator is already registered", () => { - beforeEach(async () => { - await sortition.publicInsertOperator(alice.address, weightA) - }) - it("should revert", async () => { + await sortition.publicInsertOperator(alice.address, weightA) await expect( sortition.publicInsertOperator(alice.address, weightB), ).to.be.revertedWith("Operator is already registered in the pool") @@ -108,47 +144,38 @@ describe("SortitionTree", () => { describe("getOperatorID", () => { context("when operator is inserted", () => { - beforeEach(async () => { - await sortition.publicInsertOperator(alice.address, 0xfff0) - }) - it("should return the id of the operator", async () => { + await sortition.publicInsertOperator(alice.address, 0xfff0) const aliceID = await sortition.getOperatorID(alice.address) expect(aliceID).to.be.equal(1) }) + }) - it("should return zero id when the operator is unknown", async () => { - const bobID = await sortition.getOperatorID(bob.address) - expect(bobID).to.be.equal(0) - }) + it("should return zero id when the operator is unknown", async () => { + const bobID = await sortition.getOperatorID(bob.address) + expect(bobID).to.be.equal(0) }) }) describe("getIDOperator", () => { context("when operator is inserted", () => { - beforeEach(async () => { - await sortition.publicInsertOperator(alice.address, 0xfff0) - }) - it("should return the address of the operator by their id", async () => { + await sortition.publicInsertOperator(alice.address, 0xfff0) const aliceAddress = await sortition.getIDOperator(1) expect(aliceAddress).to.be.equal(alice.address) }) + }) - it("should return zero address when the id of operator is unknown", async () => { - const aliceAddress = await sortition.getIDOperator(2) - expect(aliceAddress).to.be.equal(ZERO_ADDRESS) - }) + it("should return zero address when the id of operator is unknown", async () => { + const aliceAddress = await sortition.getIDOperator(2) + expect(aliceAddress).to.be.equal(ZERO_ADDRESS) }) }) describe("removeOperator", () => { context("when operator is not registered", () => { - beforeEach(async () => { - await sortition.publicInsertOperator(alice.address, 0x1234) - }) - it("should revert", async () => { + await sortition.publicInsertOperator(alice.address, 0x1234) await expect( sortition.publicRemoveOperator(bob.address), ).to.be.revertedWith("Operator is not registered in the pool") @@ -182,22 +209,16 @@ describe("SortitionTree", () => { describe("isOperatorRegistered", async () => { context("when operator is not registered", () => { - beforeEach(async () => { - await sortition.publicInsertOperator(alice.address, 0x1234) - }) - it("should return false", async () => { + await sortition.publicInsertOperator(alice.address, 0x1234) expect(await sortition.publicIsOperatorRegistered(bob.address)).to.be .false }) }) context("when operator is registered", () => { - beforeEach(async () => { - await sortition.publicInsertOperator(alice.address, 0x1234) - }) - it("should return false", async () => { + await sortition.publicInsertOperator(alice.address, 0x1234) expect(await sortition.publicIsOperatorRegistered(alice.address)).to.be .true }) @@ -206,12 +227,9 @@ describe("SortitionTree", () => { describe("updateLeaf", async () => { context("when leaf is updated", () => { - beforeEach(async () => { + it("should return the correct value for the root", async () => { await sortition.publicInsertOperator(alice.address, 0x1234) await sortition.publicUpdateLeaf(0x00000, 0x9876) - }) - - it("should return the correct value for the root", async () => { const root = await sortition.getRoot() expect(ethers.utils.hexlify(root)).to.be.equal("0x9876") }) @@ -220,19 +238,24 @@ describe("SortitionTree", () => { describe("trunk stacks", async () => { it("works as expected", async () => { + // inserted in the first position await sortition.publicInsertOperator(alice.address, 0x1234) + // inserted in the second position await sortition.publicInsertOperator(bob.address, 0x9876) await sortition.publicRemoveOperator(alice.address) const deletedLeaf = await sortition.getLeaf(0x00000) expect(deletedLeaf).to.be.equal(0) + // the first position isn't reused until we've inserted 8^7 = 2097152 + // operators. Alice is inserted in the third position. await sortition.publicInsertOperator(alice.address, 0xdead) const stillDeletedLeaf = await sortition.getLeaf(0x00000) expect(stillDeletedLeaf).to.be.equal(0) const root = await sortition.getRoot() + // 0x9876 + 0xdead = 0x17723 expect(ethers.utils.hexlify(root)).to.be.equal("0x017723") }) }) @@ -244,6 +267,8 @@ describe("SortitionTree", () => { const index1 = 450 const index2 = 451 + // alice is assigned weights [0-450] (451 values) and bob has weight + // [451-2434] (1984 values). const position1 = await sortition.publicPickWeightedLeaf(index1) expect(position1).to.be.equal(0) @@ -261,15 +286,14 @@ describe("SortitionTree", () => { }) describe("operatorsInPool", async () => { - context("when the operator is in the pool", () => { - beforeEach(async () => { - await sortition.publicInsertOperator(alice.address, 1) - }) - - it("should return true", async () => { - const nOperators = await sortition.operatorsInPool() - expect(nOperators).to.be.equal(1) - }) + it("counts the number of operators in the pool", async () => { + await sortition.publicInsertOperator(alice.address, 1) + const justAlice = await sortition.operatorsInPool() + expect(justAlice).to.be.equal(1) + + await sortition.publicInsertOperator(bob.address, 1) + const aliceAndBob = await sortition.operatorsInPool() + expect(aliceAndBob).to.be.equal(2) }) })