feat: initialize Kurdistan SDK - independent fork of Polkadot SDK
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Disputes Subsystems
|
||||
|
||||
If approval voting finds an invalid candidate, a dispute is raised. The disputes
|
||||
subsystems are concerned with the following:
|
||||
|
||||
1. Disputes can be raised
|
||||
1. Disputes (votes) get propagated to all other validators
|
||||
1. Votes get recorded as necessary
|
||||
1. Nodes will participate in disputes in a sensible fashion
|
||||
1. Finality is stopped while a candidate is being disputed on chain
|
||||
1. Chains can be reverted in case a dispute concludes invalid
|
||||
1. Votes are provided to the provisioner for importing on chain, in order for
|
||||
slashing to work.
|
||||
|
||||
The dispute-coordinator subsystem interfaces with the provisioner and chain
|
||||
selection to make the bulk of this possible. `dispute-distribution` is concerned
|
||||
with getting votes out to other validators and receiving them in a spam
|
||||
resilient way.
|
||||
@@ -0,0 +1,659 @@
|
||||
# Dispute Coordinator
|
||||
|
||||
The coordinator is the central subsystem of the node-side components which participate in disputes. It wraps a database,
|
||||
which is used to track statements observed by _all_ validators over some window of sessions. Votes older than this
|
||||
session window are pruned.
|
||||
|
||||
In particular the dispute-coordinator is responsible for:
|
||||
|
||||
- Ensuring that the node is able to raise a dispute in case an invalid candidate is found during approval checking.
|
||||
- Ensuring that backing and approval votes will be recorded on chain. With these votes on chain we can be certain that
|
||||
appropriate targets for slashing will be available for concluded disputes. Also, scraping these votes during a dispute
|
||||
is necessary for critical spam prevention measures.
|
||||
- Ensuring backing votes will never get overridden by explicit votes.
|
||||
- Coordinating actual participation in a dispute, ensuring that the node participates in any justified dispute in a way
|
||||
that ensures resolution of disputes on the network even in the case of many disputes raised (flood/DoS scenario).
|
||||
- Ensuring disabled validators are not able to spam disputes.
|
||||
- Ensuring disputes resolve, even for candidates on abandoned forks as much as reasonably possible, to rule out "free
|
||||
tries" and thus guarantee our gambler's ruin property.
|
||||
- Providing an API for chain selection, so we can prevent finalization of any chain which has included candidates for
|
||||
which a dispute is either ongoing or concluded invalid and avoid building on chains with an included invalid
|
||||
candidate.
|
||||
- Providing an API for retrieving (resolved) disputes, including all votes, both implicit (approval, backing) and
|
||||
explicit dispute votes. So validators can get rewarded/slashed accordingly.
|
||||
|
||||
## Ensuring That Disputes Can Be Raised
|
||||
|
||||
If a candidate turns out invalid in approval checking, the `approval-voting` subsystem will try to issue a dispute. For
|
||||
this, it will send a message `DisputeCoordinatorMessage::IssueLocalStatement` to the dispute coordinator, indicating to
|
||||
cast an explicit invalid vote. It is the responsibility of the dispute coordinator on reception of such a message to
|
||||
create and sign that explicit invalid vote and trigger a dispute if none for that candidate is already ongoing.
|
||||
|
||||
In order to raise a dispute, a node has to be able to provide two opposing votes. Given that the reason of the backing
|
||||
phase is to have validators with skin in the game, the opposing valid vote will very likely be a backing vote. It could
|
||||
also be some already cast approval vote, but the significant point here is: As long as we have backing votes available,
|
||||
any node will be able to raise a dispute.
|
||||
|
||||
Therefore a vital responsibility of the dispute coordinator is to make sure backing votes are available for all
|
||||
candidates that might still get disputed. To accomplish this task in an efficient way the dispute-coordinator relies on
|
||||
chain scraping. Whenever a candidate gets backed on chain, we record in chain storage the backing votes imported in that
|
||||
block. This way, given the chain state for a given relay chain block, we can retrieve via a provided runtime API the
|
||||
backing votes imported by that block. The dispute coordinator makes sure to query those votes for any non finalized
|
||||
blocks: In case of missed blocks, it will do chain traversal as necessary.
|
||||
|
||||
Relying on chain scraping is very efficient for two reasons:
|
||||
|
||||
1. Votes are already batched. We import all available backing votes for a candidate all at once. If instead we imported
|
||||
votes from candidate-backing as they came along, we would import each vote individually which is inefficient in the
|
||||
current dispute coordinator implementation (quadratic complexity).
|
||||
2. We also import less votes in total, as we avoid importing statements for candidates that never got successfully
|
||||
backed on any chain.
|
||||
|
||||
It also is secure, because disputes are only ever raised in the approval voting phase. A node only starts the approval
|
||||
process after it has seen a candidate included on some chain, for that to happen it must have been backed previously.
|
||||
Therefore backing votes are available at that point in time. Signals are processed first, so even if a block is skipped
|
||||
and we only start importing backing votes on the including block, we will have seen the backing votes by the time we
|
||||
process messages from approval voting.
|
||||
|
||||
In summary, for making it possible for a dispute to be raised, recording of backing votes from chain is sufficient and
|
||||
efficient. In particular there is no need to preemptively import approval votes, which has shown to be a very
|
||||
inefficient process. (Quadratic complexity adds up, with 35 votes in total per candidate)
|
||||
|
||||
Approval votes are very relevant nonetheless as we are going to see in the next section.
|
||||
|
||||
## Ensuring approval votes will be recorded
|
||||
|
||||
### Ensuring Recording
|
||||
|
||||
Only votes recorded by the dispute coordinator will be considered for slashing.
|
||||
|
||||
While there is no need to record approval votes in the dispute coordinator preemptively, we make some effort to have any
|
||||
in approval-voting received approval votes recorded when a dispute actually happens:
|
||||
|
||||
This is not required for concluding the dispute, as nodes send their own vote anyway (either explicit valid or their
|
||||
existing approval-vote). What nodes can do though, is participating in approval-voting, casting a vote, but later when a
|
||||
dispute is raised reconsider their vote and send an explicit invalid vote. If they managed to only have that one
|
||||
recorded, then they could avoid a slash.
|
||||
|
||||
This is not a problem for our basic security assumptions: The backers are the ones to be supposed to have skin in the
|
||||
game, so we are not too worried about colluding approval voters getting away slash free as the gambler's ruin property is
|
||||
maintained anyway. There is however a separate problem, from colluding approval-voters, that is "lazy" approval voters.
|
||||
If it were easy and reliable for approval-voters to reconsider their vote, in case of an actual dispute, then they don't
|
||||
have a direct incentive (apart from playing a part in securing the network) to properly run the validation function at
|
||||
all - they could just always vote "valid" totally risk free. (While they would always risk a slash by voting invalid.)
|
||||
|
||||
|
||||
So we do want to fetch approval votes from approval-voting. Importing votes is most efficient when batched. At the same
|
||||
time approval voting and disputes are running concurrently so approval votes are expected to trickle in still, when a
|
||||
dispute is already ongoing.
|
||||
|
||||
Hence, we have the following requirements for importing approval votes:
|
||||
|
||||
1. Only import them when there is a dispute, because otherwise we are wasting lots of resources _always_ for the
|
||||
exceptional case of a dispute.
|
||||
2. Import votes batched when possible, to avoid quadratic import complexity.
|
||||
3. Take into account that approval voting is still ongoing, while a dispute is already running.
|
||||
|
||||
With a design where approval voting sends votes to the dispute-coordinator by itself, we would need to make approval
|
||||
voting aware of ongoing disputes and once it is aware it could start sending all already existing votes batched and
|
||||
trickling in votes as they come. The problem with this is, that it adds some unnecessary complexity to approval-voting
|
||||
and also we might still import most of the votes unbatched one-by-one, depending on what point in time the dispute was
|
||||
raised.
|
||||
|
||||
Instead of the dispute coordinator informing approval-voting of an ongoing dispute for it to begin forwarding votes to
|
||||
the dispute coordinator, it makes more sense for the dispute-coordinator to just ask approval-voting for votes of
|
||||
candidates in dispute. This way, the dispute coordinator can also pick the best time for maximizing the number of votes
|
||||
in the batch.
|
||||
|
||||
Now the question remains, when should the dispute coordinator ask approval-voting for votes?
|
||||
|
||||
In fact for slashing it is only relevant to have them once the dispute concluded, so we can query approval voting the
|
||||
moment the dispute concludes! Two concerns that come to mind, are easily addressed:
|
||||
|
||||
1. Timing: We would like to rely as little as possible on implementation details of approval voting. In particular, if
|
||||
the dispute is ongoing for a long time, do we have any guarantees that approval votes are kept around long enough by
|
||||
approval voting? Will approval votes still be present by the time the dispute concludes in all cases? The answer is
|
||||
nuanced, but in general we cannot rely on it. The problem is first, that finalization and approval-voting is an
|
||||
off-chain process so there is no global consensus: As soon as at least f+1 honest (f=n/3, where n is the number of
|
||||
validators/nodes) nodes have seen the dispute conclude, finalization will take place and approval votes will be
|
||||
cleared. This would still be fine, if we had some guarantees that those honest nodes will be able to include those
|
||||
votes in a block. This guarantee does not exist unfortunately, we will discuss the problem and solutions in more
|
||||
detail [below][#Ensuring Chain Import].
|
||||
|
||||
The second problem is that approval-voting will abandon votes as soon as a chain can no longer be finalized (some
|
||||
other/better fork already has been). This second problem can somehow be mitigated by also importing votes as soon as
|
||||
a dispute is detected, but not fully resolved. It is still inherently racy. The good thing is, this should be good
|
||||
enough: We are worried about lazy approval checkers, the system does not need to be perfect. It should be enough if
|
||||
there is some risk of getting caught.
|
||||
2. We are not worried about the dispute not concluding, as nodes will always send their own vote, regardless of it being
|
||||
an explicit or an already existing approval-vote.
|
||||
|
||||
Conclusion: As long as we make sure, if our own approval vote gets imported (which would prevent dispute participation)
|
||||
to also distribute it via dispute-distribution, disputes can conclude. To mitigate raciness with approval-voting
|
||||
deleting votes we will import approval votes twice during a dispute: Once when it is raised, to make as sure as possible
|
||||
to see approval votes also for abandoned forks and second when the dispute concludes, to maximize the amount of
|
||||
potentially malicious approval votes to be recorded. The raciness obviously is not fully resolved by this, but this is
|
||||
fine as argued above.
|
||||
|
||||
Ensuring vote import on chain is covered in the next section.
|
||||
|
||||
What we don't care about is that honest approval-voters will likely validate twice, once in approval voting and once via
|
||||
dispute-participation. Avoiding that does not really seem worthwhile though, as disputes are for one exceptional, so a
|
||||
little wasted effort won't affect everyday performance - second, even with eager importing of approval votes, those
|
||||
doubled work is still present as disputes and approvals are racing. Every time participation is faster than approval, a
|
||||
node would do double work.
|
||||
|
||||
### Ensuring Chain Import
|
||||
|
||||
While in the previous section we discussed means for nodes to ensure relevant votes are recorded so lazy approval
|
||||
checkers get slashed properly, it is crucial to also discuss the actual chain import. Only if we guarantee that recorded
|
||||
votes will get imported on chain (on all potential chains really) we will succeed in executing slashes. Particularly we
|
||||
need to make sure backing votes end up on chain consistently.
|
||||
|
||||
Dispute distribution will make sure all explicit dispute votes get distributed among nodes which includes current block
|
||||
producers (current authority set) which is an important property: If the dispute carries on across an era change, we
|
||||
need to ensure that the new validator set will learn about any disputes and their votes, so they can put that
|
||||
information on chain. Dispute-distribution luckily has this property and always sends votes to the current authority
|
||||
set. The issue is, for dispute-distribution, nodes send only their own explicit (or in some cases their approval vote)
|
||||
in addition to some opposing vote. This guarantees that at least some backing or approval vote will be present at the
|
||||
block producer, but we don't have a 100% guarantee to have votes for all backers, even less for approval checkers.
|
||||
|
||||
Reason for backing votes: While backing votes will be present on at least some chain, that does not mean that any such
|
||||
chain is still considered for block production in the current set - they might only exist on an already abandoned fork.
|
||||
This means a block producer that just joined the set, might not have seen any of them.
|
||||
|
||||
For approvals it is even more tricky and less necessary: Approval voting together with finalization is a completely
|
||||
off-chain process therefore those protocols don't care about block production at all. Approval votes only have a
|
||||
guarantee of being propagated between the nodes that are responsible for finalizing the concerned blocks. This implies
|
||||
that on an era change the current authority set, will not necessarily get informed about any approval votes for the
|
||||
previous era. Hence even if all validators of the previous era successfully recorded all approval votes in the dispute
|
||||
coordinator, they won't get a chance to put them on chain, hence they won't be considered for slashing.
|
||||
|
||||
It is important to note, that the essential properties of the system still hold: Dispute-distribution will distribute at
|
||||
_least one_ "valid" vote to the current authority set, hence at least one node will get slashed in case of outcome
|
||||
"invalid". Also in reality the validator set is rarely exchanged 100%, therefore in practice some validators in the
|
||||
current authority set will overlap with the ones in the previous set and will be able to record votes on chain.
|
||||
|
||||
Still, for maximum accountability we need to make sure a previous authority set can communicate votes to the next one,
|
||||
regardless of any chain: This is yet to be implemented see section "Resiliency" in dispute-distribution and
|
||||
[this](https://github.com/paritytech/polkadot/issues/3398) ticket.
|
||||
|
||||
## Coordinating Actual Dispute Participation
|
||||
|
||||
Once the dispute coordinator learns about a dispute, it is its responsibility to make sure the local node participates
|
||||
in that dispute.
|
||||
|
||||
The dispute coordinator learns about a dispute by importing votes from either chain scraping or from
|
||||
dispute-distribution. If it finds opposing votes (always the case when coming from dispute-distribution), it records the
|
||||
presence of a dispute. Then, in case it does not find any local vote for that dispute already, it needs to trigger
|
||||
participation in the dispute (see previous section for considerations when the found local vote is an approval vote).
|
||||
|
||||
Participation means, recovering availability and re-evaluating the POV. The result of that validation (either valid or
|
||||
invalid) will be the node's vote on that dispute: Either explicit "invalid" or "valid". The dispute coordinator will
|
||||
inform `dispute-distribution` about our vote and `dispute-distribution` will make sure that our vote gets distributed to
|
||||
all other validators.
|
||||
|
||||
Nothing ever is that easy though. We can not blindly import anything that comes along and trigger participation no
|
||||
matter what.
|
||||
|
||||
### Spam Considerations
|
||||
|
||||
In Pezkuwi's security model, it is important that attempts to attack the system result in a slash of the offenders.
|
||||
Therefore we need to make sure that this slash is actually happening. Attackers could try to prevent the slashing from
|
||||
taking place, by overwhelming validators with disputes in such a way that no single dispute ever concludes, because
|
||||
nodes are busy processing newly incoming ones. Other attacks are imaginable as well, like raising disputes for
|
||||
candidates that don't exist, just filling up everyone's disk slowly or worse making nodes try to participate, which will
|
||||
result in lots of network requests for recovering availability.
|
||||
|
||||
The last point brings up a significant consideration in general: Disputes are about escalation: Every node will suddenly
|
||||
want to check, instead of only a few. A single message will trigger the whole network to start significant amount of
|
||||
work and will cause lots of network traffic and messages. Hence the dispute system is very susceptible to being a brutal
|
||||
amplifier for DoS attacks, resulting in DoS attacks to become very easy and cheap, if we are not careful.
|
||||
|
||||
One counter measure we are taking is making raising of disputes a costly thing: If you raise a dispute, because you
|
||||
claim a candidate is invalid, although it is in fact valid - you will get slashed, hence you pay for consuming those
|
||||
resources. The issue is: This only works if the dispute concerns a candidate that actually exists!
|
||||
|
||||
If a node raises a dispute for a candidate that never got included (became available) on any chain, then the dispute can
|
||||
never conclude, hence nobody gets slashed. It makes sense to point out that this is less bad than it might sound at
|
||||
first, as trying to participate in a dispute for a non existing candidate is "relatively" cheap. Each node will send out
|
||||
a few hundred tiny request messages for availability chunks, which all will end up in a tiny response "NoSuchChunk" and
|
||||
then no participation will actually happen as there is nothing to participate. Malicious nodes could provide chunks,
|
||||
which would make things more costly, but at the full expense of the attackers bandwidth - no amplification here. I am
|
||||
bringing that up for completeness only: Triggering a thousand nodes to send out a thousand tiny network messages by just
|
||||
sending out a single garbage message, is still a significant amplification and is nothing to ignore - this could
|
||||
absolutely be used to cause harm!
|
||||
|
||||
### Participation
|
||||
|
||||
As explained, just blindly participating in any "dispute" that comes along is not a good idea. First we would like to
|
||||
make sure the dispute is actually genuine, to prevent cheap DoS attacks. Secondly, in case of genuine disputes, we would
|
||||
like to conclude one after the other, in contrast to processing all at the same time, slowing down progress on all of
|
||||
them, bringing individual processing to a complete halt in the worst case (nodes get overwhelmed at some stage in the
|
||||
pipeline).
|
||||
|
||||
To ensure to only spend significant work on genuine disputes, we only trigger participation at all on any _vote import_
|
||||
if any of the following holds true:
|
||||
|
||||
- We saw the disputed candidate included in some not yet finalized block on at least one fork of the chain.
|
||||
- We have seen the disputed candidate backed in some not yet finalized block on at least one fork of the chain. This
|
||||
ensures the candidate is at least not completely made up and there has been some effort already flown into that
|
||||
candidate. Generally speaking a dispute shouldn't be raised for a candidate which is backed but is not yet included.
|
||||
Disputes are raised during approval checking. We participate on such disputes as a precaution - maybe we haven't seen
|
||||
the `CandidateIncluded` event yet?
|
||||
- The dispute is already confirmed: Meaning that 1/3+1 nodes already participated, as this suggests in our threat model
|
||||
that there was at least one honest node that already voted, so the dispute must be genuine.
|
||||
|
||||
In addition to that, we only participate in a non-confirmed dispute if at least one vote against the candidate is from
|
||||
a non-disabled validator.
|
||||
|
||||
Note: A node might be out of sync with the chain and we might only learn about a block, including a candidate, after we
|
||||
learned about the dispute. This means, we have to re-evaluate participation decisions on block import!
|
||||
|
||||
With this, nodes won't waste significant resources on completely made up candidates. The next step is to process dispute
|
||||
participation in a (globally) ordered fashion. Meaning a majority of validators should arrive at at least roughly at the
|
||||
same ordering of participation, for disputes to get resolved one after another. This order is only relevant if there are
|
||||
lots of disputes, so we obviously only need to worry about order if participations start queuing up.
|
||||
|
||||
We treat participation for candidates that we have seen included with priority and put them on a priority queue which
|
||||
sorts participation based on the block number of the relay parent of the candidate and for candidates with the same
|
||||
relay parent height further by the `CandidateHash`. This ordering is globally unique and also prioritizes older
|
||||
candidates.
|
||||
|
||||
The latter property makes sense, because if an older candidate turns out invalid, we can roll back the full chain at
|
||||
once. If we resolved earlier disputes first and they turned out invalid as well, we might need to roll back a couple of
|
||||
times instead of just once to the oldest offender. This is obviously a good idea, in particular it makes it impossible
|
||||
for an attacker to prevent rolling back a very old candidate, by keeping raising disputes for newer candidates.
|
||||
|
||||
For candidates we have not seen included, but we know are backed (thanks to chain scraping) or we have seen a dispute
|
||||
with 1/3+1 participation (confirmed dispute) on them - we put participation on a best-effort queue. It has got the same
|
||||
ordering as the priority one - by block heights of the relay parent, older blocks are with priority. There is a
|
||||
possibility not to be able to obtain the block number of the parent when we are inserting the dispute in the queue. To
|
||||
account for races, we will promote any existing participation request to the priority queue once we learn about an
|
||||
including block. NOTE: this is still work in progress and is tracked by [this
|
||||
issue](https://github.com/paritytech/polkadot/issues/5875).
|
||||
|
||||
### Abandoned Forks
|
||||
|
||||
Finalization: As mentioned we care about included and backed candidates on any non-finalized chain, given that any
|
||||
disputed chain will not get finalized, we don't need to care about finalized blocks, but what about forks that fall
|
||||
behind the finalized chain in terms of block number? For those we would still like to be able to participate in any
|
||||
raised disputes, otherwise attackers might be able to avoid a slash if they manage to create a better fork after they
|
||||
learned about the approval checkers. Therefore we do care about those forks even after they have fallen behind the
|
||||
finalized chain.
|
||||
|
||||
For simplicity we also care about the actual finalized chain (not just forks) up to a certain depth. We do have to limit
|
||||
the depth, because otherwise we open a DoS vector again. The depth (into the finalized chain) should be oriented on the
|
||||
approval-voting execution timeout, in particular it should be significantly larger. Otherwise by the time the execution
|
||||
is allowed to finish, we already dropped information about those candidates and the dispute could not conclude.
|
||||
|
||||
## Import
|
||||
|
||||
### Spam Considerations
|
||||
|
||||
In the last section we looked at how to treat queuing participations to handle heavy dispute load well. This already
|
||||
ensures, that honest nodes won't amplify cheap DoS attacks. There is one minor issue remaining: Even if we delay
|
||||
participation until we have some confirmation of the authenticity of the dispute, we should also not blindly import all
|
||||
votes arriving into the database as this might be used to just slowly fill up disk space, until the node is no longer
|
||||
functional. This leads to our last protection mechanism at the dispute coordinator level (dispute-distribution also has
|
||||
its own), which is spam slots. For each import containing an invalid vote, where we don't know whether it might be spam
|
||||
or not we increment a counter for each signing participant of explicit `invalid` votes.
|
||||
|
||||
What votes do we treat as a potential spam? A vote will increase a spam slot if and only if all of the following
|
||||
conditions are satisfied:
|
||||
|
||||
- the candidate under dispute was not seen included nor backed on any chain
|
||||
- the dispute is not confirmed
|
||||
- we haven't cast a vote for the dispute
|
||||
- at least one vote against the candidate is from a non-disabled validator
|
||||
|
||||
Whenever any vote on a dispute is imported these conditions are checked. If the dispute is found not to be potential
|
||||
spam, then spam slots for the disputed candidate hash are cleared. This decrements the spam count for every validator
|
||||
which had voted invalid.
|
||||
|
||||
To keep spam slots from filling up unnecessarily we want to clear spam slots whenever a candidate is seen to be backed
|
||||
or included. Fortunately this behavior is achieved by clearing slots on vote import as described above. Because on chain
|
||||
backing votes are processed when a block backing the disputed candidate is discovered, spam slots are cleared for every
|
||||
backed candidate. Included candidates have also been seen as backed on the same fork, so decrementing spam slots is
|
||||
handled in that case as well.
|
||||
|
||||
The reason this works is because we only need to worry about actual dispute votes. Import of backing votes are already
|
||||
rate limited and concern only real candidates. For approval votes a similar argument holds (if they come from
|
||||
approval-voting), but we also don't import them until a dispute already concluded. For actual dispute votes we need two
|
||||
opposing votes, so there must be an explicit `invalid` vote in the import. Only a third of the validators can be
|
||||
malicious, so spam disk usage is limited to `2*vote_size*n/3*NUM_SPAM_SLOTS`, with `n` being the number of validators.
|
||||
|
||||
### Disabling
|
||||
|
||||
Once a validator has committed an offence (e.g. losing a dispute), it is considered disabled for the rest of the era.
|
||||
In addition to using the on-chain state of disabled validators, we also keep track of validators who lost a dispute
|
||||
off-chain. The reason for this is a dispute can be raised for a candidate in a previous era, which means that a
|
||||
validator that is going to be slashed for it might not even be in the current active set. That means it can't be
|
||||
disabled on-chain. We need a way to prevent someone from disputing all valid candidates in the previous era. We do this
|
||||
by keeping track of the validators who lost a dispute in the past few sessions and use that list in addition to the
|
||||
on-chain disabled validators state. In addition to past session misbehavior, this also helps in case a slash is delayed.
|
||||
|
||||
When we receive a dispute statements set, we do the following:
|
||||
1. Take the on-chain state of disabled validators at the relay parent block.
|
||||
1. Take a list of those who lost a dispute in that session in the order that prioritizes the biggest and newest offence.
|
||||
1. Combine the two lists and take the first byzantine threshold validators from it.
|
||||
1. If the dispute is unconfirmed, check if all votes against the candidate are from disabled validators.
|
||||
If so, we don't participate in the dispute, but record the votes.
|
||||
|
||||
### Backing Votes
|
||||
|
||||
Backing votes are in some way special. For starters they are the only valid votes that are guaranteed to exist for any
|
||||
valid dispute to be raised. Second they are the only votes that commit to a shorter execution timeout
|
||||
`BACKING_EXECUTION_TIMEOUT`, compared to a more lenient timeout used in approval voting. To account properly for
|
||||
execution time variance across machines, slashing might treat backing votes differently (more aggressively) than other
|
||||
voting `valid` votes. Hence in import we shall never override a backing vote with another valid vote. They can not be
|
||||
assumed to be interchangeable.
|
||||
|
||||
## Attacks & Considerations
|
||||
|
||||
The following attacks on the priority queue and best-effort queues are considered in above design.
|
||||
|
||||
### Priority Queue
|
||||
|
||||
On the priority queue, we will only queue participations for candidates we have seen included on any chain. Any attack
|
||||
attempt would start with a candidate included on some chain, but an attacker could try to only reveal the including
|
||||
relay chain blocks to just some honest validators and stop as soon as it learns that some honest validator would have a
|
||||
relevant approval assignment.
|
||||
|
||||
Without revealing the including block to any honest validator, we don't really have an attack yet. Once the block is
|
||||
revealed though, the above is actually very hard. Each honest validator will re-distribute the block it just learned
|
||||
about. This means an attacker would need to pull of a targeted DoS attack, which allows the validator to send its
|
||||
assignment, but prevents it from forwarding and sharing the relay chain block.
|
||||
|
||||
This sounds already hard enough, provided that we also start participation if we learned about an including block after
|
||||
the dispute has been raised already (we need to update participation queues on new leaves), but to be even safer we
|
||||
choose to have an additional best-effort queue.
|
||||
|
||||
### Best-Effort Queue
|
||||
|
||||
While attacking the priority queue is already pretty hard, attacking the best-effort queue is even harder. For a
|
||||
candidate to be a threat, it has to be included on some chain. For it to be included, it has to have been backed before
|
||||
and at least n/3 honest nodes must have seen that block, so availability (inclusion) can be reached. Making a full third
|
||||
of the nodes not further propagate a block, while at the same time allowing them to fetch chunks, sign and distribute
|
||||
bitfields seems almost infeasible and even if accomplished, those nodes would be enough to confirm a dispute and we have
|
||||
not even touched the above fact that in addition, for an attack, the following including block must be shared with
|
||||
honest validators as well.
|
||||
|
||||
It is worth mentioning that a successful attack on the priority queue as outlined above is already outside of our threat
|
||||
model, as it assumes n/3 malicious nodes + additionally malfunctioning/DoSed nodes. Even more so for attacks on the
|
||||
best-effort queue, as our threat model only allows for n/3 malicious _or_ malfunctioning nodes in total. It would
|
||||
therefore be a valid decision to ditch the best-effort queue, if it proves to become a burden or creates other issues.
|
||||
|
||||
One issue we should not be worried about though is spam. For abusing best-effort for spam, the following scenario would
|
||||
be necessary:
|
||||
|
||||
An attacker controls a backing group: The attacker can then have candidates backed and choose to not provide chunks.
|
||||
This should come at a cost to miss out on rewards for backing, so is not free. At the same time it is rate limited, as a
|
||||
backing group can only back so many candidates legitimately. (~ 1 per slot):
|
||||
|
||||
1. They have to wait until a malicious actor becomes block producer (for causing additional forks via equivocation for
|
||||
example).
|
||||
2. Forks are possible, but if caused by equivocation also not free.
|
||||
3. For each fork the attacker has to wait until the candidate times out, for backing another one.
|
||||
|
||||
Assuming there can only be a handful of forks, 2) together with 3) the candidate timeout restriction, frequency should
|
||||
indeed be in the ballpark of once per slot. Scaling linearly in the number of controlled backing groups, so two groups
|
||||
would mean 2 backings per slot, ...
|
||||
|
||||
So by this reasoning an attacker could only do very limited harm and at the same time will have to pay some price for it
|
||||
(it will miss out on rewards). Overall the work done by the network might even be in the same ballpark as if actors just
|
||||
behaved honestly:
|
||||
|
||||
1. Validators would have fetched chunks
|
||||
2. Approval checkers would have done approval checks
|
||||
|
||||
While because of the attack (backing, not providing chunks and afterwards disputing the candidate), the work for 1000
|
||||
validators would be:
|
||||
|
||||
All validators sending out ~ 1000 tiny requests over already established connections, with also tiny (byte) responses.
|
||||
|
||||
This means around a million requests, while in the honest case it would be ~ 10000 (30 approval checkers x330) - where
|
||||
each request triggers a response in the range of kilobytes. Hence network load alone will likely be higher in the honest
|
||||
case than in the DoS attempt case, which would mean the DoS attempt actually reduces load, while also costing rewards.
|
||||
|
||||
In the worst case this can happen multiple times, as we would retry that on every vote import. The effect would still be
|
||||
in the same ballpark as honest behavior though and can also be mitigated by chilling repeated availability recovery
|
||||
requests for example.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
### No Disputes for Non Included Candidates
|
||||
|
||||
We only ever care about disputes for candidates that have been included on at least some chain (became available). This
|
||||
is because the availability system was designed for precisely that: Only with inclusion (availability) we have
|
||||
guarantees about the candidate to actually be available. Because only then we have guarantees that malicious backers can
|
||||
be reliably checked and slashed. Also, by design non included candidates do not pose any threat to the system.
|
||||
|
||||
One could think of an (additional) dispute system to make it possible to dispute any candidate that has been proposed by
|
||||
a validator, no matter whether it got successfully included or even backed. Unfortunately, it would be very brittle (no
|
||||
availability) and also spam protection would be way harder than for the disputes handled by the dispute-coordinator. In
|
||||
fact, all the spam handling strategies described above would simply be unavailable.
|
||||
|
||||
It is worth thinking about who could actually raise such disputes anyway: Approval checkers certainly not, as they will
|
||||
only ever check once availability succeeded. The only other nodes that meaningfully could/would are honest backing nodes
|
||||
or collators. For collators spam considerations would be even worse as there can be an unlimited number of them and we
|
||||
can not charge them for spam, so trying to handle disputes raised by collators would be even more complex. For honest
|
||||
backers: It actually makes more sense for them to wait until availability is reached as well, as only then they have
|
||||
guarantees that other nodes will be able to check. If they disputed before, all nodes would need to recover the data
|
||||
from them, so they would be an easy DoS target.
|
||||
|
||||
In summary: The availability system was designed for raising disputes in a meaningful and secure way after availability
|
||||
was reached. Trying to raise disputes before does not meaningfully contribute to the systems security/might even weaken
|
||||
it as attackers are warned before availability is reached, while at the same time adding significant amount of
|
||||
complexity. We therefore punt on such disputes and concentrate on disputes the system was designed to handle.
|
||||
|
||||
### No Disputes for Already Finalized Blocks
|
||||
|
||||
Note that by above rules in the `Participation` section, we will not participate in disputes concerning a candidate in
|
||||
an already finalized block. This is because, disputing an already finalized block is simply too late and therefore of
|
||||
little value. Once finalized, bridges have already processed the block for example, so we have to assume the damage is
|
||||
already done. Governance has to step in and fix what can be fixed.
|
||||
|
||||
Making disputes for already finalized blocks possible would only provide two features:
|
||||
|
||||
1. We can at least still slash attackers.
|
||||
2. We can freeze the chain to some governance only mode, in an attempt to minimize potential harm done.
|
||||
|
||||
Both seem kind of worthwhile, although as argued above, it is likely that there is not too much that can be done in 2
|
||||
and we would likely only ending up DoSing the whole system without much we can do. 1 can also be achieved via governance
|
||||
mechanisms.
|
||||
|
||||
In any case, our focus should be making as sure as reasonably possible that any potentially invalid block does not get
|
||||
finalized in the first place. Not allowing disputing already finalized blocks actually helps a great deal with this goal
|
||||
as it massively reduces the amount of candidates that can be disputed.
|
||||
|
||||
This makes attempts to overwhelm the system with disputes significantly harder and counter measures way easier. We can
|
||||
limit inclusion for example (as suggested [here](https://github.com/paritytech/polkadot/issues/5898) in case of high
|
||||
dispute load. Another measure we have at our disposal is that on finality lag block production will slow down,
|
||||
implicitly reducing the rate of new candidates that can be disputed. Hence, the cutting-off of the unlimited candidate
|
||||
supply of already finalized blocks, guarantees the necessary DoS protection and ensures we can have measures in place to
|
||||
keep up with processing of disputes.
|
||||
|
||||
If we allowed participation for disputes for already finalized candidates, the above spam protection mechanisms would be
|
||||
insufficient/relying 100% on full and quick disabling of spamming validators.
|
||||
|
||||
## Database Schema
|
||||
|
||||
We use an underlying Key-Value database where we assume we have the following operations available:
|
||||
- `write(key, value)`
|
||||
- `read(key) -> Option<value>`
|
||||
- `iter_with_prefix(prefix) -> Iterator<(key, value)>` - gives all keys and values in lexicographical order where the
|
||||
key starts with `prefix`.
|
||||
|
||||
We use this database to encode the following schema:
|
||||
|
||||
```rust
|
||||
("candidate-votes", SessionIndex, CandidateHash) -> Option<CandidateVotes>
|
||||
"recent-disputes" -> RecentDisputes
|
||||
"earliest-session" -> Option<SessionIndex>
|
||||
```
|
||||
|
||||
The meta information that we track per-candidate is defined as the `CandidateVotes` struct. This draws on the [dispute
|
||||
statement types][DisputeTypes]
|
||||
|
||||
```rust
|
||||
/// Tracked votes on candidates, for the purposes of dispute resolution.
|
||||
pub struct CandidateVotes {
|
||||
/// The receipt of the candidate itself.
|
||||
pub candidate_receipt: CandidateReceipt,
|
||||
/// Votes of validity, sorted by validator index.
|
||||
pub valid: Vec<(ValidDisputeStatementKind, ValidatorIndex, ValidatorSignature)>,
|
||||
/// Votes of invalidity, sorted by validator index.
|
||||
pub invalid: Vec<(InvalidDisputeStatementKind, ValidatorIndex, ValidatorSignature)>,
|
||||
}
|
||||
|
||||
/// The mapping for recent disputes; any which have not yet been pruned for being ancient.
|
||||
pub type RecentDisputes = std::collections::BTreeMap<(SessionIndex, CandidateHash), DisputeStatus>;
|
||||
|
||||
/// The status of dispute. This is a state machine which can be altered by the
|
||||
/// helper methods.
|
||||
pub enum DisputeStatus {
|
||||
/// The dispute is active and unconcluded.
|
||||
Active,
|
||||
/// The dispute has been concluded in favor of the candidate
|
||||
/// since the given timestamp.
|
||||
ConcludedFor(Timestamp),
|
||||
/// The dispute has been concluded against the candidate
|
||||
/// since the given timestamp.
|
||||
///
|
||||
/// This takes precedence over `ConcludedFor` in the case that
|
||||
/// both are true, which is impossible unless a large amount of
|
||||
/// validators are participating on both sides.
|
||||
ConcludedAgainst(Timestamp),
|
||||
/// Dispute has been confirmed (more than `byzantine_threshold` have already participated/ or
|
||||
/// we have seen the candidate included already/participated successfully ourselves).
|
||||
Confirmed,
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
Input: [`DisputeCoordinatorMessage`][DisputeCoordinatorMessage]
|
||||
|
||||
Output:
|
||||
- [`RuntimeApiMessage`][RuntimeApiMessage]
|
||||
|
||||
## Functionality
|
||||
|
||||
This assumes a constant `DISPUTE_WINDOW: SessionWindowSize`. This should correspond to at least 1 day.
|
||||
|
||||
Ephemeral in-memory state:
|
||||
|
||||
```rust
|
||||
struct State {
|
||||
keystore: Arc<LocalKeystore>,
|
||||
rolling_session_window: RollingSessionWindow,
|
||||
highest_session: SessionIndex,
|
||||
spam_slots: SpamSlots,
|
||||
participation: Participation,
|
||||
ordering_provider: OrderingProvider,
|
||||
participation_receiver: WorkerMessageReceiver,
|
||||
metrics: Metrics,
|
||||
// This tracks only rolling session window failures.
|
||||
// It can be a `Vec` if the need to track more arises.
|
||||
error: Option<SessionsUnavailable>,
|
||||
/// Latest relay blocks that have been successfully scraped.
|
||||
last_scraped_blocks: LruMap<Hash, ()>,
|
||||
}
|
||||
```
|
||||
|
||||
### On startup
|
||||
|
||||
When the subsystem is initialised it waits for a new leaf (message `OverseerSignal::ActiveLeaves`). The leaf is used to
|
||||
initialise a `RollingSessionWindow` instance (contains leaf hash and `DISPUTE_WINDOW` which is a constant).
|
||||
|
||||
Next the active disputes are loaded from the DB and initialize spam slots accordingly, then for each loaded dispute, we
|
||||
either send a `DisputeDistribution::SendDispute` if there is a local vote from us available or if there is none and
|
||||
participation is in order, we push the dispute to participation.
|
||||
|
||||
### The main loop
|
||||
|
||||
Just after the subsystem initialisation the main loop (`fn run_until_error()`) runs until `OverseerSignal::Conclude`
|
||||
signal is received. Before executing the actual main loop the leaf and the participations, obtained during startup are
|
||||
enqueued for processing. If there is capacity (the number of running participations is less than
|
||||
`MAX_PARALLEL_PARTICIPATIONS`) participation jobs are started (`func participate`). Finally the component waits for
|
||||
messages from Overseer. The behaviour on each message is described in the following subsections.
|
||||
|
||||
### On `OverseerSignal::ActiveLeaves`
|
||||
|
||||
Initiates processing via the `Participation` module and updates the internal state of the subsystem. More concretely:
|
||||
|
||||
- Passes the `ActiveLeavesUpdate` message to the ordering provider.
|
||||
- Updates the session info cache.
|
||||
- Updates `self.highest_session`.
|
||||
- Prunes old spam slots in case the session window has advanced.
|
||||
- Scrapes on chain votes.
|
||||
|
||||
### On `MuxedMessage::Participation`
|
||||
|
||||
This message is sent from `Participation` module and indicates a processed dispute participation. It's the result of
|
||||
the processing job initiated with `OverseerSignal::ActiveLeaves`. The subsystem issues a `DisputeMessage` with the
|
||||
result.
|
||||
|
||||
### On `OverseerSignal::Conclude`
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
### On `OverseerSignal::BlockFinalized`
|
||||
|
||||
Performs cleanup of the finalized candidate.
|
||||
|
||||
### On `DisputeCoordinatorMessage::ImportStatements`
|
||||
|
||||
Import statements by validators are processed in `fn handle_import_statements()`. The function has got three main
|
||||
responsibilities:
|
||||
- Initiate participation in disputes and sending out of any existing own approval vote in case of a raised dispute.
|
||||
- Persist all fresh votes in the database. Fresh votes in this context means votes that are not already processed by the
|
||||
node.
|
||||
- Spam protection on all invalid (`DisputeStatement::Invalid`) votes. Please check the SpamSlots section for details on
|
||||
how spam protection works.
|
||||
|
||||
### On `DisputeCoordinatorMessage::RecentDisputes`
|
||||
|
||||
Returns all recent disputes saved in the DB.
|
||||
|
||||
### On `DisputeCoordinatorMessage::ActiveDisputes`
|
||||
|
||||
Returns all recent disputes concluded within the last `ACTIVE_DURATION_SECS` .
|
||||
|
||||
### On `DisputeCoordinatorMessage::QueryCandidateVotes`
|
||||
|
||||
Loads `candidate-votes` for every `(SessionIndex, CandidateHash)` in the input query and returns data within each
|
||||
`CandidateVote`. If a particular `candidate-vote` is missing, that particular request is omitted from the response.
|
||||
|
||||
### On `DisputeCoordinatorMessage::IssueLocalStatement`
|
||||
|
||||
Executes `fn issue_local_statement()` which performs the following operations:
|
||||
|
||||
- Deconstruct into parts `{ session_index, candidate_hash, candidate_receipt, is_valid }`.
|
||||
- Construct a [`DisputeStatement`][DisputeStatement] based on `Valid` or `Invalid`, depending on the parameterization of
|
||||
this routine.
|
||||
- Sign the statement with each key in the `SessionInfo`'s list of teyrchain validation keys which is present in the
|
||||
keystore, except those whose indices appear in `voted_indices`. This will typically just be one key, but this does
|
||||
provide some future-proofing for situations where the same node may run on behalf multiple validators. At the time of
|
||||
writing, this is not a use-case we support as other subsystems do not invariably provide this guarantee.
|
||||
- Write statement to DB.
|
||||
- Send a `DisputeDistributionMessage::SendDispute` message to get the vote distributed to other validators.
|
||||
|
||||
### On `DisputeCoordinatorMessage::DetermineUndisputedChain`
|
||||
|
||||
Executes `fn determine_undisputed_chain()` which performs the following:
|
||||
|
||||
- Load `"recent-disputes"`.
|
||||
- Deconstruct into parts `{ base_number, block_descriptions, rx }`
|
||||
- Starting from the beginning of `block_descriptions`:
|
||||
1. Check the `RecentDisputes` for a dispute of each candidate in the block description.
|
||||
1. If there is a dispute which is active or concluded negative, exit the loop.
|
||||
- For the highest index `i` reached in the `block_descriptions`, send `(base_number + i + 1, block_hash)` on the
|
||||
channel, unless `i` is 0, in which case `None` should be sent. The `block_hash` is determined by inspecting
|
||||
`block_descriptions[i]`.
|
||||
|
||||
[DisputeTypes]: ../../types/disputes.md
|
||||
[DisputeStatement]: ../../types/disputes.md#disputestatement
|
||||
[DisputeCoordinatorMessage]: ../../types/overseer-protocol.md#dispute-coordinator-message
|
||||
[RuntimeApiMessage]: ../../types/overseer-protocol.md#runtime-api-message
|
||||
@@ -0,0 +1,429 @@
|
||||
# Dispute Distribution
|
||||
|
||||
Dispute distribution is responsible for ensuring all concerned validators will
|
||||
be aware of a dispute and have the relevant votes.
|
||||
|
||||
## Design Goals
|
||||
|
||||
This design should result in a protocol that is:
|
||||
|
||||
- resilient to nodes being temporarily unavailable
|
||||
- make sure nodes are aware of a dispute quickly
|
||||
- relatively efficient, should not cause too much stress on the network
|
||||
- be resilient when it comes to spam
|
||||
- be simple and boring: We want disputes to work when they happen
|
||||
|
||||
## Protocol
|
||||
|
||||
Distributing disputes needs to be a reliable protocol. We would like to make as
|
||||
sure as possible that our vote got properly delivered to all concerned
|
||||
validators. For this to work, this subsystem won't be gossip based, but instead
|
||||
will use a request/response protocol for application level confirmations. The
|
||||
request will be the payload (the actual votes/statements), the response will
|
||||
be the confirmation. See [below][#wire-format].
|
||||
|
||||
### Input
|
||||
|
||||
[`DisputeDistributionMessage`][DisputeDistributionMessage]
|
||||
|
||||
### Output
|
||||
|
||||
- [`DisputeCoordinatorMessage::ActiveDisputes`][DisputeCoordinatorMessage]
|
||||
- [`DisputeCoordinatorMessage::ImportStatements`][DisputeCoordinatorMessage]
|
||||
- [`DisputeCoordinatorMessage::QueryCandidateVotes`][DisputeCoordinatorMessage]
|
||||
- [`RuntimeApiMessage`][RuntimeApiMessage]
|
||||
|
||||
### Wire format
|
||||
|
||||
#### Disputes
|
||||
|
||||
Protocol: `"/<genesis_hash>/<fork_id>/send_dispute/1"`
|
||||
|
||||
Request:
|
||||
|
||||
```rust
|
||||
struct DisputeRequest {
|
||||
/// The candidate being disputed.
|
||||
pub candidate_receipt: CandidateReceipt,
|
||||
|
||||
/// The session the candidate appears in.
|
||||
pub session_index: SessionIndex,
|
||||
|
||||
/// The invalid vote data that makes up this dispute.
|
||||
pub invalid_vote: InvalidDisputeVote,
|
||||
|
||||
/// The valid vote that makes this dispute request valid.
|
||||
pub valid_vote: ValidDisputeVote,
|
||||
}
|
||||
|
||||
/// Any invalid vote (currently only explicit).
|
||||
pub struct InvalidDisputeVote {
|
||||
/// The voting validator index.
|
||||
pub validator_index: ValidatorIndex,
|
||||
|
||||
/// The validator signature, that can be verified when constructing a
|
||||
/// `SignedDisputeStatement`.
|
||||
pub signature: ValidatorSignature,
|
||||
|
||||
/// Kind of dispute statement.
|
||||
pub kind: InvalidDisputeStatementKind,
|
||||
}
|
||||
|
||||
/// Any valid vote (backing, approval, explicit).
|
||||
pub struct ValidDisputeVote {
|
||||
/// The voting validator index.
|
||||
pub validator_index: ValidatorIndex,
|
||||
|
||||
/// The validator signature, that can be verified when constructing a
|
||||
/// `SignedDisputeStatement`.
|
||||
pub signature: ValidatorSignature,
|
||||
|
||||
/// Kind of dispute statement.
|
||||
pub kind: ValidDisputeStatementKind,
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```rust
|
||||
enum DisputeResponse {
|
||||
Confirmed
|
||||
}
|
||||
```
|
||||
|
||||
#### Vote Recovery
|
||||
|
||||
Protocol: `"/<genesis_hash>/<fork_id>/req_votes/1"`
|
||||
|
||||
```rust
|
||||
struct IHaveVotesRequest {
|
||||
candidate_hash: CandidateHash,
|
||||
session: SessionIndex,
|
||||
valid_votes: Bitfield,
|
||||
invalid_votes: Bitfield,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```rust
|
||||
struct VotesResponse {
|
||||
/// All votes we have, but the requester was missing.
|
||||
missing: Vec<(DisputeStatement, ValidatorIndex, ValidatorSignature)>,
|
||||
}
|
||||
```
|
||||
|
||||
## Starting a Dispute
|
||||
|
||||
A dispute is initiated once a node sends the first `DisputeRequest` wire message,
|
||||
which must contain an "invalid" vote and a "valid" vote.
|
||||
|
||||
The dispute distribution subsystem can get instructed to send that message out to
|
||||
all concerned validators by means of a `DisputeDistributionMessage::SendDispute`
|
||||
message. That message must contain an invalid vote from the local node and some
|
||||
valid one, e.g. a backing statement.
|
||||
|
||||
We include a valid vote as well, so any node regardless of whether it is synced
|
||||
with the chain or not or has seen backing/approval vote can see that there are
|
||||
conflicting votes available, hence we have a valid dispute. Nodes will still
|
||||
need to check whether the disputing votes are somewhat current and not some
|
||||
stale ones.
|
||||
|
||||
## Participating in a Dispute
|
||||
|
||||
Upon receiving a `DisputeRequest` message, a dispute distribution will trigger the
|
||||
import of the received votes via the dispute coordinator
|
||||
(`DisputeCoordinatorMessage::ImportStatements`). The dispute coordinator will
|
||||
take care of participating in that dispute if necessary. Once it is done, the
|
||||
coordinator will send a `DisputeDistributionMessage::SendDispute` message to dispute
|
||||
distribution. From here, everything is the same as for starting a dispute,
|
||||
except that if the local node deemed the candidate valid, the `SendDispute`
|
||||
message will contain a valid vote signed by our node and will contain the
|
||||
initially received `Invalid` vote.
|
||||
|
||||
Note, that we rely on `dispute-coordinator` to check validity of a dispute for spam
|
||||
protection (see below).
|
||||
|
||||
## Sending of messages
|
||||
|
||||
Starting and participating in a dispute are pretty similar from the perspective
|
||||
of dispute distribution. Once we receive a `SendDispute` message, we try to make
|
||||
sure to get the data out. We keep track of all the teyrchain validators that
|
||||
should see the message, which are all the teyrchain validators of the session
|
||||
where the dispute happened as they will want to participate in the dispute. In
|
||||
addition we also need to get the votes out to all authorities of the current
|
||||
session (which might be the same or not and may change during the dispute).
|
||||
Those authorities will not participate in the dispute, but need to see the
|
||||
statements so they can include them in blocks.
|
||||
|
||||
### Reliability
|
||||
|
||||
We only consider a message transmitted, once we received a confirmation message.
|
||||
If not, we will keep retrying getting that message out as long as the dispute is
|
||||
deemed alive. To determine whether a dispute is still alive we will ask the
|
||||
`dispute-coordinator` for a list of all still active disputes via a
|
||||
`DisputeCoordinatorMessage::ActiveDisputes` message before each retry run. Once
|
||||
a dispute is no longer live, we will clean up the state accordingly.
|
||||
|
||||
### Order
|
||||
|
||||
We assume `SendDispute` messages are coming in an order of importance, hence
|
||||
`dispute-distribution` will make sure to send out network messages in the same
|
||||
order, even on retry.
|
||||
|
||||
### Rate Limit
|
||||
|
||||
For spam protection (see below), we employ an artificial rate limiting on sending
|
||||
out messages in order to not hit the rate limit at the receiving side, which
|
||||
would result in our messages getting dropped and our reputation getting reduced.
|
||||
|
||||
## Reception
|
||||
|
||||
As we shall see the receiving side is mostly about handling spam and ensuring
|
||||
the dispute-coordinator learns about disputes as fast as possible.
|
||||
|
||||
Goals for the receiving side:
|
||||
|
||||
1. Get new disputes to the dispute-coordinator as fast as possible, so
|
||||
prioritization can happen properly.
|
||||
2. Batch votes per disputes as much as possible for good import performance.
|
||||
3. Prevent malicious nodes exhausting node resources by sending lots of messages.
|
||||
4. Prevent malicious nodes from sending so many messages/(fake) disputes,
|
||||
preventing us from concluding good ones.
|
||||
5. Limit ability of malicious nodes of delaying the vote import due to batching
|
||||
logic.
|
||||
|
||||
Goal 1 and 2 seem to be conflicting, but an easy compromise is possible: When
|
||||
learning about a new dispute, we will import the vote immediately, making the
|
||||
dispute coordinator aware and also getting immediate feedback on the validity.
|
||||
Then if valid we can batch further incoming votes, with less time constraints as
|
||||
the dispute-coordinator already knows about the dispute.
|
||||
|
||||
Goal 3 and 4 are obviously very related and both can easily be solved via rate
|
||||
limiting as we shall see below. Rate limits should already be implemented at the
|
||||
Substrate level, but [are not](https://github.com/paritytech/substrate/issues/7750)
|
||||
at the time of writing. But even if they were, the enforced Substrate limits would
|
||||
likely not be configurable and thus would still be to high for our needs as we can
|
||||
rely on the following observations:
|
||||
|
||||
1. Each honest validator will only send one message (apart from duplicates on
|
||||
timeout) per candidate/dispute.
|
||||
2. An honest validator needs to fully recover availability and validate the
|
||||
candidate for casting a vote.
|
||||
|
||||
With these two observations, we can conclude that honest validators will usually
|
||||
not send messages at a high rate. We can therefore enforce conservative rate
|
||||
limits and thus minimize harm spamming malicious nodes can have.
|
||||
|
||||
Before we dive into how rate limiting solves all spam issues elegantly, let's
|
||||
discuss that honest behaviour further:
|
||||
|
||||
What about session changes? Here we might have to inform a new validator set of
|
||||
lots of already existing disputes at once.
|
||||
|
||||
With observation 1) and a rate limit that is per peer, we are still good:
|
||||
|
||||
Let's assume a rate limit of one message per 200ms per sender. This means 5
|
||||
messages from each validator per second. 5 messages means 5 disputes!
|
||||
Conclusively, we will be able to conclude 5 disputes per second - no matter what
|
||||
malicious actors are doing. This is assuming dispute messages are sent ordered,
|
||||
but even if not perfectly ordered: On average it will be 5 disputes per second.
|
||||
|
||||
This is good enough! All those disputes are valid ones and will result in
|
||||
slashing and disabling of validators. Let's assume all of them conclude `valid`,
|
||||
and we disable validators only after 100 raised concluding valid disputes, we
|
||||
would still start disabling misbehaving validators in only 20 seconds.
|
||||
|
||||
One could also think that in addition participation is expected to take longer,
|
||||
which means on average we can import/conclude disputes faster than they are
|
||||
generated - regardless of dispute spam. Unfortunately this is not necessarily
|
||||
true: There might be teyrchains with very light load where recovery and
|
||||
validation can be accomplished very quickly - maybe faster than we can import
|
||||
those disputes.
|
||||
|
||||
This is probably an argument for not imposing a too low rate limit, although the
|
||||
issue is more general: Even without any rate limit, if an attacker generates
|
||||
disputes at a very high rate, nodes will be having trouble keeping participation
|
||||
up, hence the problem should be mitigated at a [more fundamental
|
||||
layer](https://github.com/paritytech/polkadot/issues/5898).
|
||||
|
||||
For nodes that have been offline for a while, the same argument as for session
|
||||
changes holds, but matters even less: We assume 2/3 of nodes to be online, so
|
||||
even if the worst case 1/3 offline happens and they could not import votes fast
|
||||
enough (as argued above, they in fact can) it would not matter for consensus.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
As suggested previously, rate limiting allows to mitigate all threats that come
|
||||
from malicious actors trying to overwhelm the system in order to get away without
|
||||
a slash, when it comes to dispute-distribution. In this section we will explain
|
||||
how in greater detail.
|
||||
|
||||
The idea is to open a queue with limited size for each peer. We will process
|
||||
incoming messages as fast as we can by doing the following:
|
||||
|
||||
1. Check that the sending peer is actually a valid authority - otherwise drop
|
||||
message and decrease reputation/disconnect.
|
||||
2. Put message on the peer's queue, if queue is full - drop it.
|
||||
|
||||
Every `RATE_LIMIT` seconds (or rather milliseconds), we pause processing
|
||||
incoming requests to go a full circle and process one message from each queue.
|
||||
Processing means `Batching` as explained in the next section.
|
||||
|
||||
### Batching
|
||||
|
||||
To achieve goal 2 we will batch incoming votes/messages together before passing
|
||||
them on as a single batch to the `dispute-coordinator`. To adhere to goal 1 as
|
||||
well, we will do the following:
|
||||
|
||||
1. For an incoming message, we check whether we have an existing batch for that
|
||||
candidate, if not we import directly to the dispute-coordinator, as we have
|
||||
to assume this is concerning a new dispute.
|
||||
2. We open a batch and start collecting incoming messages for that candidate,
|
||||
instead of immediately forwarding.
|
||||
3. We keep collecting votes in the batch until we receive less than
|
||||
`MIN_KEEP_BATCH_ALIVE_VOTES` unique votes in the last `BATCH_COLLECTING_INTERVAL`. This is
|
||||
important to accommodate for goal 5 and also 3.
|
||||
4. We send the whole batch to the dispute-coordinator.
|
||||
|
||||
This together with rate limiting explained above ensures we will be able to
|
||||
process valid disputes: We can limit the number of simultaneous existing batches
|
||||
to some high value, but can be rather certain that this limit will never be
|
||||
reached - hence we won't drop valid disputes:
|
||||
|
||||
Let's assume `MIN_KEEP_BATCH_ALIVE_VOTES` is 10, `BATCH_COLLECTING_INTERVAL`
|
||||
is `500ms` and above `RATE_LIMIT` is `100ms`. 1/3 of validators are malicious,
|
||||
so for 1000 this means around 330 malicious actors worst case.
|
||||
|
||||
All those actors can send a message every `100ms`, that is 10 per second. This
|
||||
means at the beginning of an attack they can open up around 3300 batches. Each
|
||||
containing two votes. So memory usage is still negligible. In reality it is even
|
||||
less, as we also demand 10 new votes to trickle in per batch in order to keep it
|
||||
alive, every `500ms`. Hence for the first second, each batch requires 20 votes
|
||||
each. Each message is 2 votes, so this means 10 messages per batch. Hence to
|
||||
keep those batches alive 10 attackers are needed for each batch. This reduces
|
||||
the number of opened batches by a factor of 10: So we only have 330 batches in 1
|
||||
second - each containing 20 votes.
|
||||
|
||||
The next second: In order to further grow memory usage, attackers have to
|
||||
maintain 10 messages per batch and second. Number of batches equals the number
|
||||
of attackers, each has 10 messages per second, all are needed to maintain the
|
||||
batches in memory. Therefore we have a hard cap of around 330 (number of
|
||||
malicious nodes) open batches. Each can be filled with number of malicious
|
||||
actor's votes. So 330 batches with each 330 votes: Let's assume approximately 100
|
||||
bytes per signature/vote. This results in a worst case memory usage of
|
||||
`330 * 330 * 100 ~= 10 MiB`.
|
||||
|
||||
For 10_000 validators, we are already in the Gigabyte range, which means that
|
||||
with a validator set that large we might want to be more strict with the rate limit or
|
||||
require a larger rate of incoming votes per batch to keep them alive.
|
||||
|
||||
For a thousand validators a limit on batches of around 1000 should never be
|
||||
reached in practice. Hence due to rate limiting we have a very good chance to
|
||||
not ever having to drop a potential valid dispute due to some resource limit.
|
||||
|
||||
Further safe guards are possible: The dispute-coordinator actually
|
||||
confirms/denies imports. So once we receive a denial by the dispute-coordinator
|
||||
for the initial imported votes, we can opt into flushing the batch immediately
|
||||
and importing the votes. This swaps memory usage for more CPU usage, but if that
|
||||
import is deemed invalid again we can immediately decrease the reputation of the
|
||||
sending peers, so this should be a net win. For the time being we punt on this
|
||||
for simplicity.
|
||||
|
||||
Instead of filling batches to maximize memory usage, attackers could also try to
|
||||
overwhelm the dispute coordinator by only sending votes for new candidates all
|
||||
the time. This attack vector is mitigated also by above rate limit and
|
||||
decreasing the peer's reputation on denial of the invalid imports by the
|
||||
coordinator.
|
||||
|
||||
### Node Startup
|
||||
|
||||
Nothing special happens on node startup. We expect the `dispute-coordinator` to
|
||||
inform us about any ongoing disputes via `SendDispute` messages.
|
||||
|
||||
## Backing and Approval Votes
|
||||
|
||||
Backing and approval votes get imported when they arrive/are created via the
|
||||
dispute coordinator by corresponding subsystems.
|
||||
|
||||
We assume that under normal operation each node will be aware of backing and
|
||||
approval votes and optimize for that case. Nevertheless we want disputes to
|
||||
conclude fast and reliable, therefore if a node is not aware of backing/approval
|
||||
votes it can request the missing votes from the node that informed it about the
|
||||
dispute (see [Resiliency](#Resiliency])
|
||||
|
||||
## Resiliency
|
||||
|
||||
The above protocol should be sufficient for most cases, but there are certain
|
||||
cases we also want to have covered:
|
||||
|
||||
- Non validator nodes might be interested in ongoing voting, even before it is
|
||||
recorded on chain.
|
||||
- Nodes might have missed votes, especially backing or approval votes.
|
||||
Recovering them from chain is difficult and expensive, due to runtime upgrades
|
||||
and untyped extrinsics.
|
||||
- More importantly, on era changes the new authority set, from the perspective
|
||||
of approval-voting have no need to see "old" approval votes, hence they might
|
||||
not see them, can therefore not import them into the dispute coordinator and
|
||||
therefore no authority will put them on chain.
|
||||
|
||||
To cover those cases, we introduce a second request/response protocol, which can
|
||||
be handled on a lower priority basis as the one above. It consists of the
|
||||
request/response messages as described in the [protocol
|
||||
section][#vote-recovery].
|
||||
|
||||
Nodes may send those requests to validators, if they feel they are missing
|
||||
votes. E.g. after some timeout, if no majority was reached yet in their point of
|
||||
view or if they are not aware of any backing/approval votes for a received
|
||||
disputed candidate.
|
||||
|
||||
The receiver of a `IHaveVotesRequest` message will do the following:
|
||||
|
||||
1. See if the sender is missing votes we are aware of - if so, respond with
|
||||
those votes.
|
||||
2. Check whether the sender knows about any votes, we don't know about and if so
|
||||
send a `IHaveVotesRequest` request back, with our knowledge.
|
||||
3. Record the peer's knowledge.
|
||||
|
||||
When to send `IHaveVotesRequest` messages:
|
||||
|
||||
1. Whenever we are asked to do so via
|
||||
`DisputeDistributionMessage::FetchMissingVotes`.
|
||||
2. Approximately once per block to some random validator as long as the dispute
|
||||
is active.
|
||||
|
||||
Spam considerations: Nodes want to accept those messages once per validator and
|
||||
per slot. They are free to drop more frequent requests or requests for stale
|
||||
data. Requests coming from non validator nodes, can be handled on a best effort
|
||||
basis.
|
||||
|
||||
## Considerations
|
||||
|
||||
Dispute distribution is critical. We should keep track of available validator
|
||||
connections and issue warnings if we are not connected to a majority of
|
||||
validators. We should also keep track of failed sending attempts and log
|
||||
warnings accordingly. As disputes are rare and TCP is a reliable protocol,
|
||||
probably each failed attempt should trigger a warning in logs and also logged
|
||||
into some Prometheus metric.
|
||||
|
||||
## Disputes for non available candidates
|
||||
|
||||
If deemed necessary we can later on also support disputes for non available
|
||||
candidates, but disputes for those cases have totally different requirements.
|
||||
|
||||
First of all such disputes are not time critical. We just want to have
|
||||
some offender slashed at some point, but we have no risk of finalizing any bad
|
||||
data.
|
||||
|
||||
Second, as we won't have availability for such data, the node that initiated the
|
||||
dispute will be responsible for providing the disputed data initially. Then
|
||||
nodes which did the check already are also providers of the data, hence
|
||||
distributing load and making prevention of the dispute from concluding harder
|
||||
and harder over time. Assuming an attacker can not DoS a node forever, the
|
||||
dispute will succeed eventually, which is all that matters. And again, even if
|
||||
an attacker managed to prevent such a dispute from happening somehow, there is
|
||||
no real harm done: There was no serious attack to begin with.
|
||||
|
||||
[DisputeDistributionMessage]: ../../types/overseer-protocol.md#dispute-distribution-message
|
||||
[RuntimeApiMessage]: ../../types/overseer-protocol.md#runtime-api-message
|
||||
Reference in New Issue
Block a user