Stake Based Priority Queuing
In the below post I try and layout some ideas I had bouncing around in my head on preventing Spam and Denial of Service Attacks in a peer to peer (P2P) network - which is mainly achieved by introducing optional staking on an address or peer id.
Staking as a signal
The purpose of the staking contract is to enable node in a P2P network to apply rate based limiting, especially in the case of high congestion. To keep things simple a node should have two queuing buckets: a priority queue and a free tier queue e.g.
- Free Bucket (1Mbps) - Routes/Forwards traffic any in and outbound traffic. - Priority Bucket (2Mbps) - Routes/Forwards traffic in and outbound traffic, weighted by stake.
These settings are obviously just recommended, and each node can pick it’s own settings. The benefit of the staking signal (and therefore the spam resistance) is obviously stronger based on how much of the node elects to each bucket. Having two buckets allows flexibility in terms of how alturistic a node wants to be.
The rest of the post will be discussing how the weighting works in the Priority Bucket.
The Priority Bucket
The priority bucket requires a message to be signed cryptographically by a proof that is stored on the staking contract. For a simple ECC proof one could easily sign it on a per-message basis; for more computationally expensive proofs one could sign based on a epoch time (every 5 minutes for example). To keep things simple, we can just assume address + ECC proof of address is used for the rest of the post.
The staking weight is calculated by the simple formula:
final_weight(<address>) = balanceOf(<address>) / totalSupply. The staking contract starts with zero totalSupply; and the stake in the contract is purchased using a custom purchase function that ensures some unique attributes.
Once the node confirms a message/connection/circuit as a valid staked weight, the message/connection/circuit is placed and forwarded in the priority bucket accordingly.
Even though initially weight calculations are expensive; the subsequent caching of weights and or balances should make this rather trivial (if you require ~128 bytes per address, you could cache ~1 million values in 128 MB of RAM). To prevent caching attacks, one would probably just catch up on the latest contract state, before starting to forward or looking up proofs.
Per Address Purchase price
The per-address purchase price of credits; is limited by an exponential function I quite like 1/x^2. This means that purchasing additional routing/forwarding power becomes increasingly difficult and expensive.
balance_to_add(<address>, amount_to_purchase) = amount_to_purchase *(1/(1 + (amount_to_purchase + BalanceOf(<address>)^2))
Purchasing of stake in the Staking Contract requires a minimum purchase price as well. The stake of any participant can be withdrawn from the contract, but an nonredeemable deposit will always remain. For the sake of a reasonable example - you are required to purchase a minimum of 1 of stake and withdraw 0.5 of the stake.
The bonus of making the Staking contract ERC20 compatible is that we can also transfer weight to other users up until the nonredeemable deposit, however transfers would have to be double taxed (with the existing per address formula and minimum requirements) to ensure transfers are not used as means to purchase too much forwarding power. This could also be simplified to just keep a running purchase balance per address, meaning even if transfers are cheap and are not taxed, additional purchases will be priced exponentially.
ERC20 compatibility also allows more free-market economics to come into play, by allowing participants to purchase and price credits directly in a DEX (yes I am thinking of Uniswap).
So how does this protect a node?
Because of the
balanceOf(<address>) / totaSupply final weight calculation combined with a minimum stake and unredeemable deposit; an attacker would have to purchase an ever increasing amount to try and flood a P2P Network.
Example Staking Contract
MINIMUM_STAKE: constant(uint256(wei)) = 1 * 10 ** 17 NONREFUNDABLE_DEPOSIT: constant(uint256) = 5 * 10 ** 16 # ... ERC20 stuff @constant @private def get_amount_to_add(current_balance: uint256, amount: uint256(wei)) -> uint256(1/wei): return amount * (1 / (1 + (amount + current_balance) ** 2)) @public @payable def purchase_credits(node: address): assert msg.value > MINIMUM_STAKE assert msg.sender == node current_balance: uint256 = self.balanceOf[node] self.balanceOf[node] += as_unitless_number( self.get_amount_to_add(current_balance, msg.value) ) @public def witdraw_credits(node: address, amount: uint256): assert msg.sender == node current_balance: uint256 = self.balanceOf[node] assert current_balance > NONREFUNDABLE_DEPOSIT send(node, current_balance - NONREFUNDABLE_DEPOSIT)
Variations on the the above
- Instead of having an per-address limiting purchasing formula, one uses a curve based on the total supply; I am not too keen on this as this benefits early adoptors way too much.
- Calculate the weight using
weight(<src_address>, <dst_address>) = (balanceOf(<dst_address>) * balanceOf(<src_address>)) / totalSupply. Which brings some interesting things into play. If you wanted a message to a have very high guarantees of reaching a participant you could transfer some credits into their account (if you know what proof they used of course). And if someone was under attack, they could just keep transferring away their stake and get payed to be spammed
- If you did the rate limiting based on the just on the proof - and just account for the proof linked packets that you forward, one could add an additional layer of anonymity.
- One big part that makes this trickier, is how much a node reveals of it’s identity by the proof that gets used in the contract.
balanceOf(<address>) / totalSupplymight not de-escalate fast enough. Which means you could tie all addresses into one dynamic purchase price dependant on