diff --git a/parameterized/proposal_inverter.py b/parameterized/proposal_inverter.py index 13d37fa..5394bf8 100644 --- a/parameterized/proposal_inverter.py +++ b/parameterized/proposal_inverter.py @@ -5,6 +5,8 @@ from collections import defaultdict from eth_account import Account +from .whitelist_mechanism import WhitelistMechanism, NoVote, OwnerVote, PayerVote, EqualVote, WeightedVote, UnanimousVote + pn.extension() @@ -103,6 +105,8 @@ class ProposalInverter(Wallet): current_epoch = pm.Number(0, doc="number of epochs that have passed") cancel_epoch = pm.Number(0, doc="last epoch where minimum conditions were been met") payer_contributions = pm.Dict(defaultdict(int), doc="maps each payer's public key to their accumulated contribution") + broker_whitelist = pm.ClassSelector(WhitelistMechanism, default=OwnerVote()) + payer_whitelist = pm.ClassSelector(WhitelistMechanism, default=NoVote()) # Parameters min_stake = pm.Number(5, doc="minimum funds that a broker must stake to join") @@ -126,7 +130,7 @@ def __init__(self, owner: Wallet, initial_funds: float, **params): self.committed_brokers = set() self.started = self._minimum_start_conditions_met() - + def add_broker(self, broker: Wallet, stake: float): """ A broker can join the agreement (and must also join the stream associated with that agreement) by staking the @@ -147,7 +151,7 @@ def add_broker(self, broker: Wallet, stake: float): print("Failed to add broker, minimum stake not met") elif self.cancelled: print("Failed to add broker, proposal has been cancelled") - else: + elif self.broker_whitelist.in_whitelist(broker): broker.funds -= stake self.funds += stake self.broker_agreements[broker.public] = BrokerAgreement( @@ -157,6 +161,9 @@ def add_broker(self, broker: Wallet, stake: float): total_claimed=0 ) self.committed_brokers.add(broker) + else: + self.broker_whitelist.add_waitlist(broker) + print("Warning: broker not yet whitelisted, added to waitlist") return broker @@ -223,10 +230,11 @@ def iter_epoch(self, n_epochs: int=1): """ for epoch in range(n_epochs): if not self.cancelled: + if not self.started: + self.started = self._minimum_start_conditions_met() + if self.started: self._allocate_funds() - else: - self.started = self._minimum_start_conditions_met() self.current_epoch += 1 @@ -239,7 +247,7 @@ def _allocate_funds(self): broker_agreement.allocated_funds += self.get_broker_claimable_funds() # Use cancel_epoch to record when the cancellation condition was triggered - if self.number_of_brokers() >= self.min_brokers or self.get_horizon() >= self.min_horizon: + if self._minimum_start_conditions_met(): self.cancel_epoch = self.current_epoch # If the forced cancellation conditions are met for a period longer than the buffer period, trigger the cancel function @@ -273,10 +281,14 @@ def pay(self, payer: Wallet, tokens: float): Furthermore, the Horizon H is increased H+ = (R + ΔF)/ ΔA = H + (ΔF/ΔA) """ - payer.funds -= tokens - self.funds += tokens + if self.payer_whitelist.in_whitelist(payer): + payer.funds -= tokens + self.funds += tokens - self.payer_contributions[payer.public] += tokens + self.payer_contributions[payer.public] += tokens + else: + self.payer_whitelist.add_waitlist(payer) + print("Warning: payer not yet whitelisted, added to waitlist") return payer @@ -322,9 +334,24 @@ def _minimum_start_conditions_met(self): - If the specified minimum number of payers has been met - If the specified minimum horizon has been met """ + min_brokers_met = self.number_of_brokers() >= self.min_brokers min_payers_met = len(self.payer_contributions.keys()) >= self.min_payers min_horizon_met = self.get_horizon() >= self.min_horizon - return min_payers_met and min_horizon_met + return min_brokers_met and min_payers_met and min_horizon_met - \ No newline at end of file + def vote_broker(self, voter: Wallet, broker: Wallet, vote: bool): + """ + This is the outward facing interface to directly affect which brokers + are whitelisted. The actual mechanism is dependent on which whitelisting + mechanism is used. + """ + self.broker_whitelist.vote(self, voter, broker, vote) + + def vote_payer(self, voter: Wallet, payer: Wallet, vote: bool): + """ + This is the outward facing interface to directly affect which payers are + whitelisted. The actual mechanism is dependent on which whitelisting + mechanism is used. + """ + self.payer_whitelist.vote(self, voter, payer, vote) diff --git a/parameterized/test_proposal_inverter.py b/parameterized/test_proposal_inverter.py index 47998b2..a15376c 100644 --- a/parameterized/test_proposal_inverter.py +++ b/parameterized/test_proposal_inverter.py @@ -1,6 +1,7 @@ import pytest from .proposal_inverter import Wallet, ProposalInverter +from .whitelist_mechanism import NoVote, OwnerVote @pytest.fixture @@ -13,7 +14,7 @@ def owner(): @pytest.fixture def inverter(owner): - inverter = owner.deploy(500) + inverter = owner.deploy(500, broker_whitelist=NoVote()) return inverter @@ -175,39 +176,17 @@ def test_cancel(owner, inverter, broker1, broker2): assert inverter.funds == 0 assert inverter.get_allocated_funds() == 0 - -def test_forced_cancel_case1(broker1): - """ - First test case involves using an inverter where the minimum number of brokers is 2. If only one broker joins and - the minimum horizon is reached, then the forced cancel should be triggered and all remaining funds should be - allocated to the single broker in the inverter. - """ - # Deploy proposal inverter - owner = Wallet() - owner.funds = 1000 - inverter = owner.deploy(100, min_brokers=2) - - # Add broker - broker1 = inverter.add_broker(broker1, 10) - - # Iterate past the buffer period to trigger the forced cancel - inverter.iter_epoch(10) - - assert inverter.number_of_brokers() < inverter.min_brokers - assert inverter.get_horizon() < inverter.min_horizon - assert inverter.get_allocated_funds() == inverter.funds - -def test_forced_cancel_case2(broker1): +def test_forced_cancel_case1(broker1): """ - Second test case occurs when the inverter is below the minimum horizon and all brokers leave. In this case, there + First test case occurs when the inverter is below the minimum horizon and all brokers leave. In this case, there are no brokers to allocate the funds to, so when the forced cancel is triggered, all funds should be returned to the owner. """ # Deploy proposal inverter owner = Wallet() owner.funds = 1000 - inverter = owner.deploy(100) + inverter = owner.deploy(100, broker_whitelist=NoVote()) # Add broker broker1 = inverter.add_broker(broker1, 9) @@ -225,9 +204,9 @@ def test_forced_cancel_case2(broker1): assert inverter.get_allocated_funds() == inverter.funds -def test_forced_cancel_case3(broker1, broker2): +def test_forced_cancel_case2(broker1, broker2, payer): """ - Third test case is to ensure the forced cancel counter resets if the inverter is no longer under the minimum + Second test case is to ensure the forced cancel counter resets if the inverter is no longer under the minimum conditions. The inverter dips below the minimum conditions for a few epochs less than the specified buffer period, and the goes back up. The counter should reset, and then the inverter should dip back down and trigger the forced cancel. @@ -235,34 +214,45 @@ def test_forced_cancel_case3(broker1, broker2): # Deploy proposal inverter owner = Wallet() owner.funds = 1000 - inverter = owner.deploy(100, min_brokers=2) + inverter = owner.deploy(100, min_brokers=2, broker_whitelist=NoVote()) # Add brokers broker1 = inverter.add_broker(broker1, 10) + broker2 = inverter.add_broker(broker2, 10) + + assert inverter.funds == 120 + assert inverter.get_horizon() >= inverter.min_horizon # Dip below minimum conditions but before the forced cancel triggers inverter.iter_epoch(6) - assert inverter.number_of_brokers() < inverter.min_brokers assert inverter.get_horizon() < inverter.min_horizon assert inverter.get_allocated_funds() < inverter.funds # Add a second broker and funds to meet the minimum conditions again - broker2 = inverter.add_broker(broker2, 60) + payer = inverter.pay(payer, 60) assert inverter.number_of_brokers() >= inverter.min_brokers assert inverter.get_horizon() >= inverter.min_horizon - # Dip below minimum conditions and trigger the forced cancel - broker1 = inverter.remove_broker(broker1) - inverter.iter_epoch(6) - assert inverter.number_of_brokers() < inverter.min_brokers assert inverter.get_horizon() < inverter.min_horizon assert inverter.get_allocated_funds() < inverter.funds - inverter.iter_epoch(4) - assert inverter.get_allocated_funds() == inverter.funds +def test_owner_vote(owner, broker1, payer): + inverter = owner.deploy(500, broker_whitelist=OwnerVote()) + # Broker applies to proposal, but not yet whitelisted + broker1 = inverter.add_broker(broker1, 10) + + assert broker1.funds == 100 + assert inverter.number_of_brokers() == 0 + + # Owner whitelists broker + inverter.vote_broker(owner, broker1, True) + broker1 = inverter.add_broker(broker1, 10) + + assert broker1.funds == 90 + assert inverter.number_of_brokers() == 1 diff --git a/parameterized/test_wallet.py b/parameterized/test_wallet.py index 99b5f35..6bf3107 100644 --- a/parameterized/test_wallet.py +++ b/parameterized/test_wallet.py @@ -1,10 +1,11 @@ from .proposal_inverter import Wallet +from .whitelist_mechanism import NoVote def test_deploy_proposal_inverter(): owner = Wallet() owner.funds = 1000 - inverter = owner.deploy(500) + inverter = owner.deploy(500, broker_whitelist=NoVote()) assert inverter.funds == 500 assert inverter.current_epoch == 0 @@ -15,7 +16,7 @@ def test_remove_proposal_inverter(): # Deploy proposal inverter owner = Wallet() owner.funds = 1000 - inverter = owner.deploy(500) + inverter = owner.deploy(500, broker_whitelist=NoVote()) # Add brokers (each with a different initial stake) broker1 = Wallet() diff --git a/parameterized/test_whitelist_mechanism.py b/parameterized/test_whitelist_mechanism.py new file mode 100644 index 0000000..b46be4b --- /dev/null +++ b/parameterized/test_whitelist_mechanism.py @@ -0,0 +1,134 @@ +import pytest + +from .proposal_inverter import Wallet, ProposalInverter +from .whitelist_mechanism import NoVote, OwnerVote, PayerVote, EqualVote, WeightedVote, UnanimousVote + + +@pytest.fixture +def owner(): + owner = Wallet() + owner.funds = 500 + + return owner + + +@pytest.fixture +def payer1(): + payer1 = Wallet() + payer1.funds = 500 + + return payer1 + + +@pytest.fixture +def payer2(): + payer2 = Wallet() + payer2.funds = 500 + + return payer2 + + +@pytest.fixture +def inverter(owner, payer1, payer2): + inverter = owner.deploy(300) + payer1 = inverter.pay(payer1, 200) + payer2 = inverter.pay(payer2, 100) + + return inverter + + +@pytest.fixture +def broker(): + broker = Wallet() + broker.funds = 100 + + return broker + + +def test_no_vote(owner, payer1, inverter, broker): + mechanism = NoVote() + + # Votes should not impact whitelisting, and should whitelist all brokers + mechanism.vote(inverter, payer1, broker, False) + + assert mechanism.in_waitlist(broker) == False + assert mechanism.in_whitelist(broker) == True + + +def test_owner_vote(owner, payer1, inverter, broker): + mechanism = OwnerVote() + + # Case where payer cannot whitelist a broker + mechanism.vote(inverter, payer1, broker, True) + + assert mechanism.in_waitlist(broker) == False + assert mechanism.in_whitelist(broker) == False + + # Case where only the owner can whitelist a broker + mechanism.vote(inverter, owner, broker, True) + + assert mechanism.in_waitlist(broker) == False + assert mechanism.in_whitelist(broker) == True + + +def test_payer_vote(payer1, payer2, inverter, broker): + mechanism = PayerVote() + + # Case where any payer can whitelist a broker and override a blacklist vote + mechanism.vote(inverter, payer1, broker, False) + + assert mechanism.in_waitlist(broker) == True + assert mechanism.in_whitelist(broker) == False + + mechanism.vote(inverter, payer2, broker, True) + + assert mechanism.in_waitlist(broker) == False + assert mechanism.in_whitelist(broker) == True + + +def test_equal_vote(payer1, payer2, inverter, broker): + mechanism = EqualVote(min_vote=0.5) + + mechanism.vote(inverter, payer1, broker, True) + + assert mechanism.in_waitlist(broker) == True + assert mechanism.in_whitelist(broker) == False + + mechanism.vote(inverter, payer2, broker, True) + + assert mechanism.in_waitlist(broker) == False + assert mechanism.in_whitelist(broker) == True + + +def test_weighted_vote(payer1, payer2, inverter, broker): + mechanism = WeightedVote(min_vote=0.6) + + # Case where two voters do not have enough combined funds + mechanism.vote(inverter, payer1, broker, True) + mechanism.vote(inverter, payer2, broker, True) + + assert mechanism.in_waitlist(broker) == True + assert mechanism.in_whitelist(broker) == False + + # Case where a payer increases their funds to increase their weight + payer1 = inverter.pay(payer1, 200) + + mechanism.vote(inverter, payer1, broker, True) + + assert mechanism.in_waitlist(broker) == False + assert mechanism.in_whitelist(broker) == True + + +def test_unanimous_vote(owner, payer1, payer2, inverter, broker): + mechanism = UnanimousVote() + + mechanism.vote(inverter, payer1, broker, True) + mechanism.vote(inverter, payer2, broker, True) + + assert mechanism.in_waitlist(broker) == True + assert mechanism.in_whitelist(broker) == False + + mechanism.vote(inverter, owner, broker, True) + + assert mechanism.in_waitlist(broker) == False + assert mechanism.in_whitelist(broker) == True diff --git a/parameterized/whitelist_mechanism.py b/parameterized/whitelist_mechanism.py new file mode 100644 index 0000000..65d6e2f --- /dev/null +++ b/parameterized/whitelist_mechanism.py @@ -0,0 +1,172 @@ +import param as pm + +from abc import abstractmethod + + +class WhitelistMechanism(pm.Parameterized): + """ + This is the base class for all whitelisting mechanisms. Any new whitelisting + mechanism should override the `vote()` method and call `super().vote()` + depending on if the specified permissions are met. + + The whitelist mechanism has three stages: + + - A broker is added to the waitlist. This is where all brokers are stored + until they are whitelisted. There is no blacklist so that voters can + change their vote at a later time. + - A voter votes for a broker. Depending on the mechanism, it may require + more than a single vote for a broker to be whitelisted. + - Once enough votes for a broker has been cast, the broker is removed from + the waitlist and is added to the whitelist. + """ + votes = pm.Dict(dict(), doc="maps broker public keys to a dictionary of the vote history") + + def __init__(self, **params): + super().__init__(**params) + + self.whitelist = set() + self.waitlist = set() + + @abstractmethod + def vote(self, proposal: "ProposalInverter", voter: "Wallet", broker: "Wallet", vote: bool): + """ + This method should be overrided in all child classes. + + `super().vote()` should be called in the overrided method when a vote is + cast. + """ + if broker.public not in self.whitelist: + self.add_waitlist(broker) + self.votes[broker.public][voter.public] = vote + + def add_waitlist(self, broker: "Wallet"): + """ + Adds a broker to the waitlist if they are not already in the waitlist or + whitelist. + """ + if not self.in_whitelist(broker) and not self.in_waitlist(broker): + self.waitlist.add(broker.public) + self.votes[broker.public] = dict() + + def in_waitlist(self, broker: "Wallet"): + """ + Checks if the broker is currently in the waitlist. + """ + return broker.public in self.waitlist + + def add_whitelist(self, broker: "Wallet"): + """ + Removes a broker from the waitlist and adds them to the whitelist. + """ + self.waitlist.remove(broker.public) + self.whitelist.add(broker.public) + + def in_whitelist(self, broker: "Wallet"): + """ + Checks if the broker is currently in the whitelist. + """ + return broker.public in self.whitelist + + +class NoVote(WhitelistMechanism): + """ + This is a permissionless whitelist mechanism. In this mechanism, votes are + not counted, and all brokers are on the whitelist. + """ + def vote(self, proposal: "ProposalInverter", voter: "Wallet", broker: "Wallet", vote: bool): + pass + + def in_waitlist(self, broker: "Wallet"): + return False + + def in_whitelist(self, broker: "Wallet"): + return True + + +class OwnerVote(WhitelistMechanism): + """ + In this mechanism, the owner of the proposal has full control of which + brokers get whitelisted. Any votes cast by anyone who is not the owner are + not counted. + """ + def vote(self, proposal: "ProposalInverter", voter: "Wallet", broker: "Wallet", vote: bool): + if voter.public == proposal.owner_address: + super().vote(proposal, voter, broker, vote) + + if vote is True: + self.add_whitelist(broker) + + +class PayerVote(WhitelistMechanism): + """ + In this mechanism, any payer, including the owner, has control over which + brokers get whitelisted. Only one payer is required to approve a broker for + a broker to get whitelisted. + """ + def vote(self, proposal: "ProposalInverter", voter: "Wallet", broker: "Wallet", vote: bool): + if voter.public == proposal.owner_address or voter.public in proposal.payer_contributions.keys(): + super().vote(proposal, voter, broker, vote) + + if vote is True: + self.add_whitelist(broker) + + +class EqualVote(WhitelistMechanism): + """ + In this mechanism, each payer has equal voting power. The fraction of payers + that approve a broker must pass a minimum threshold before the broker is + whitelisted. + """ + min_vote = pm.Number(0.5, doc="the minimum percentage of votes needed to whitelist a broker") + + def vote(self, proposal: "ProposalInverter", voter: "Wallet", broker: "Wallet", vote: bool): + if voter.public in proposal.payer_contributions.keys(): + super().vote(proposal, voter, broker, vote) + + vote = sum([1 * vote for vote in self.votes[broker.public].values()]) + n_payers = len(proposal.payer_contributions) + + if vote / n_payers >= self.min_vote: + self.add_whitelist(broker) + + +class WeightedVote(WhitelistMechanism): + """ + In this mechanism, each payer's vote is weighted by their total contribution + of funds to the proposal. This means that voters who have contributed more + have more voting power. The fraction of the weighted vote that approve a + broker must pass a minimum threshold before the broker is whitelisted. + """ + min_vote = pm.Number(0.5, doc="the minimum percentage of weighted votes needed to whitelist a broker") + + def vote(self, proposal: "ProposalInverter", voter: "Wallet", broker: "Wallet", vote: bool): + if voter.public in proposal.payer_contributions.keys(): + super().vote(proposal, voter, broker, vote) + + weighted_vote = sum([ + proposal.payer_contributions[payer] * vote + for payer, vote in self.votes[broker.public].items() + ]) + total_contributions = sum(proposal.payer_contributions.values()) + + if weighted_vote / total_contributions >= self.min_vote: + self.add_whitelist(broker) + + +class UnanimousVote(WhitelistMechanism): + """ + In this mechanism, every payer must approve a broker before they are + whitelisted. The approval must be unanimous, meaning each payer has equal + veto power. + """ + def vote(self, proposal: "ProposalInverter", voter: "Wallet", broker: "Wallet", vote: bool): + if voter.public in proposal.payer_contributions.keys(): + super().vote(proposal, voter, broker, vote) + + unanimous = all([ + self.votes[broker.public].get(payer, False) + for payer in proposal.payer_contributions.keys() + ]) + + if unanimous: + self.add_whitelist(broker)