-
Notifications
You must be signed in to change notification settings - Fork 1
Add StaticRound
to eliminate some boilerplate when writing protocols
#117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
I have skimmed the code, so this is not a review. Thoughts:
Given the above I think we should provide either dynamic or static rounds, but not both. When we have a concrete use case that requires both dynamic and static we can re-assess. Is switching to only static rounds possible? It'd be interesting to see how |
For protocols themselves, yes, but evidence verification is not limited to one round and needs access to the message types from the previous rounds - so the user would have to manually transform them from some untyped form to typed messages. Although it is possible to automate deserialization (and the handling of its errors) - by making every This would need some kind of "routing" of round number -> boxed round type (not the round object, since we don't need the round state for that). This may be also used to simplify evidence checking for invalid messages (#82). |
8de19fb
to
0ef0c98
Compare
Pull Request Test Coverage Report for Build 16578768783Details
💛 - Coveralls |
Current roadblock: handling protocols where some nodes do not send or do not receive messages (e.g. KeyResharing). This means that the type of the round is not enough to determine whether a message is expected, it's also the state that's needed. In the n-of-n case the static rounds eliminate the need for |
2b67199
to
49bb410
Compare
175e8e4
to
733fd6b
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another partial review.
Ok(()) | ||
fn round_info(round_id: &RoundId) -> Option<RoundInfo<DinerId, Self>> { | ||
match round_id { | ||
_ if round_id == 1 => Some(RoundInfo::new::<Round1>()), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_ if round_id == 1
is a bit awkward looking, and I think the PartialEq
impl between RoundId
and RoundNum
actually makes it a bit worse. I tinkered a bit and came up with a few alternatives.
- More explicit, without any changes to other code:
fn round_info(round_id: &RoundId) -> Option<RoundInfo<DinerId, Self>> {
if round_id == 1 {
Some(RoundInfo::new::<Round1>())
} else if round_id == 2 {
Some(RoundInfo::new::<Round1>())
} else {
None
}
}
- Instead of using the
PartialEq
impl, add around()
method toRoundId
to make it clear what we're matching on:
fn round_info(round_id: &RoundId) -> Option<RoundInfo<DinerId, Self>> {
match round_id.round() {
1 => Some(RoundInfo::new::<Round1>()),
2 => Some(RoundInfo::new::<Round2>()),
_ => None,
}
}
- Same as 2 but adding a
to_info()
method on theRound
trait so we can emphasize the round itself:
fn round_info(round_id: &RoundId) -> Option<RoundInfo<DinerId, Self>> {
match round_id.round() {
1 => Some(Round1::to_info()),
2 => Some(Round2::to_info()),
_ => None,
}
}
Adding the to_info
method to the Round-trait requires adding + Sized
to the bounds, which might be problematic, otherwise I think 3) is the version I prefer. Regardless of which version you prefer going with, I think the PartialEq impl between RoundNum and RoundId is "too clever" and forces the reader to go deeper into the internals than what is actually useful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally, the comparison part would be fixed by #120. I like the to_info()
approach, but it's an orthogonal issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Options 2 and 3 implicitly drop the groups from round ID, which can lead to silent errors. Yes, technically at that point round IDs should not have groups, but if there's some bug in the code, they might.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
to_info()
would require a method with a default implementation in the Round
trait, which I would really like to make non-overridable, but that involves some sealed trait magic.
@@ -217,6 +200,14 @@ impl Round<DinerId> for Round1 { | |||
|
|||
impl Round<DinerId> for Round2 { | |||
type Protocol = DiningCryptographersProtocol; | |||
type ProtocolError = NoProtocolErrors<Self>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Naming nitpick: the associated type is in the singular, so it's a bit odd to assign a value whose name is plural. OTOH calling it "NoProtocolError" isn't great either. Not sure there is a great solution to be found here (idiomatically it should really be ()
but that isn't possible as we saw above).
Maybe DummyProtocolError
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A random thought I had:
I wonder how far we'd be able to get if we changed the Round
trait to make the type ProtocolError
take a impl core::error::Error
. Maybe then we could impl Round
with ()
as the error type when there are no errors, but then we'd need some clever trick to transform or cast the impl core::error::Error
into an actual ProtocolError
with all the methods and trait bounds we need.
I read through rust-lang/rust#99301 which seems to be about ways to access data from nested errors in a generic way. Seems a bit stuck though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can instead rename the associated type to ProtocolErrors
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But wouldn't the plural on a type name imply that it is a collection of types of protocol errors?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to impl ProtocolError
for core::convert::Infallible
(described as "The error type for errors that can never happen." in the docs) and it kinda works if it wasn't for the ser/deser bounds on ProtocolError
. That's sort of what I was trying to hint at by saying "it'd be so nice if we could use a impl core::error::Error and then – handwaves – transfom/cast to concrete error types": we could have less bounds.
Anyway, all of this is nitpicking. The code is fine as-is, modulo perhaps the name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can instead rename the associated type to
ProtocolErrors
?
This is better.
I still think it's awkward that there isn't a better way to do this but that has nothing to do with this PR.
}; | ||
|
||
/// An extension to a round, allowing one to extend or override its methods. | ||
pub trait Extension<Id>: 'static + Debug + Send + Sync + Clone { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While "Extension" works it is a bit too generic imo. It is specific to a round so what do you think about RoundExt
? That would be in line with other extension traits (https://rust-lang.github.io/rfcs/0445-extension-trait-conventions.html#the-convention).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure the quoted doc applies here, since it's not an extension of the Round
trait, but rather an override. But I have no problem with RoundExt
per se.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, a tricky one; you're right that it's not technically an extension trait, but maybe RoundExt
is still a better name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at this again I still think Extension
is too generic and that we need to have "round" in the name somewhere.
RoundExt
is my preferenceRoundOverride
is explicit and clearly states intent. It also jives well with astruct OverriddenRound
to replaceExtendedRound
RoundExtension
is also a possibility I guess, but it's longer and could be misunderstood into making the round itself longer in some way.
/// A wrapper for a protocol's [`EntryPoint`], allowing registering [`Extension`] implementors | ||
/// to extend or override [`Round`] methods. | ||
#[derive(Debug)] | ||
pub struct Extendable<Id: PartyId, EP: EntryPoint<Id>> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given this is pub
I think we need a less generic name. Maybe even the full ExtendableEntryPoint
. I have a slight preference for Wrapped
over Extendable
though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at this again I think we should avoid naming structs with adjectives, given the (weakly enforced) convention is to use adjectives for traits.
My suggestion here is WrappedEntryPoint
.
These methods are now internal
@@ -217,6 +200,14 @@ impl Round<DinerId> for Round1 { | |||
|
|||
impl Round<DinerId> for Round2 { | |||
type Protocol = DiningCryptographersProtocol; | |||
type ProtocolError = NoProtocolErrors<Self>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can instead rename the associated type to
ProtocolErrors
?
This is better.
I still think it's awkward that there isn't a better way to do this but that has nothing to do with this PR.
messages: EvidenceMessages<Id, Self::Round>, | ||
) -> std::result::Result<(), EvidenceError> { | ||
let _message: Round1Message = messages.direct_message()?; | ||
// Message contents would be checked here |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// Message contents would be checked here | |
// Message content would be checked here |
Given this is an example and is supposed to be educational it'd be good to elaborate on what checks one would do here, e.g. "check signatures, size limits, sessions id" (or whatever).
|
||
let message = Round1Broadcast { | ||
type Payload = Round1Payload; | ||
type Artifact = (); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Despite the other comment I still struggle with this.
If I understand things correctly this should be read as "Round 1 sends a direct message but does not use an artifact". In this sense, it is perfectly idiomatic to use ()
.
When the new manul
user later discovers that there exists a NoArtifact
type, I think there'll be confusion: which should I use?
The rule is:
- If the round uses direct messages but no artifact, use
()
here. - If the round does not use direct messages , use
NoArtifact
here.
If the user makes a mistake, it will be impossible to impl make_direct_message
but I don't think we do enough to help them figure out that they need to come here and set the associated type properly.
There's some kind of cognitive mismatch here that I can't quite put my finger on. The artifact is not merely a piece of "Associated data created alongside a message in [Self::make_direct_message
]". It is actually doing two jobs, the other being to act like a "switch" to make it possible/impossible to send direct messages.
The old signature of make_direct_message
was Result<(DirectMessage, Option<Artifact>), LocalError>
and I think that the Option<Artifact>
made the intention more clear.
I'd have to dig in deeper to know what the consequences of removing this associated type would be, but I think this is a wart.
The Round
docs hints at this with "Set to [NoArtifact
] if [Self::DirectMessage
] is [NoMessage
].", but doesn't give a deeper understanding and does not spell out when to use ()
and when to use NoArtifact
. I think at the very least that we need to explain this properly both in docs and examples.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The aim is to do something like
trait Round {
type DirectMessage: (MessageType, ArtifactType);
}
That is, the user need to either specify both of these types, or none. It is not possible to do directly in the current Rust, but kind of possible with some additional constructs, like a trait that provides an API for "splitting" a value into a message and an artifact. The problem is that this trait would have to be implemented for every direct message.
When the new manul user later discovers that there exists a NoArtifact type, I think there'll be confusion: which should I use?
There may be confusion, but at least NoArtifact
is impossible to instantiate, so it can't be used.
The old signature of make_direct_message was Result<(DirectMessage, Option), LocalError>
The problem with it is that it implies that some calls may return an artifact and some not, which is not really what we want.
Ok(Payload::new(Round1Payload)) | ||
message: ProtocolMessage<Id, Self>, | ||
) -> Result<Self::Payload, ReceiveError<Id, Self>> { | ||
assert!(message.direct_message.0 == do_work(self.round_counter + 2)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this was a debug_assert
would it be triggered in CI you think, when we run the benchmarks one time? If yes, then we could switch to that and avoid the work here.
}; | ||
|
||
/// An extension to a round, allowing one to extend or override its methods. | ||
pub trait Extension<Id>: 'static + Debug + Send + Sync + Clone { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at this again I still think Extension
is too generic and that we need to have "round" in the name somewhere.
RoundExt
is my preferenceRoundOverride
is explicit and clearly states intent. It also jives well with astruct OverriddenRound
to replaceExtendedRound
RoundExtension
is also a possibility I guess, but it's longer and could be misunderstood into making the round itself longer in some way.
// TODO (#4): we reuse `EchoBroadcast::none()` (that means `NoMessage` in the typed round) | ||
// to have a second meaning, the node not sending messages at all. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this TODO still valid?
use crate::protocol::{DeserializationError, LocalError}; | ||
use crate::protocol::LocalError; | ||
|
||
/// An error that can be returned during deserialization error. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// An error that can be returned during deserialization error. | |
/// An error that can be returned during deserialization. |
// TODO (#4): this branch is unreachable in the absense of bugs in the code | ||
// (the method will not be called in the first place if the node does not send messages). | ||
// Can it be eliminated using the type system? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're convinced about this, let's use unreachable!("This node does not send messages in this round. This is a bug in manul")
or something.
let verified = self | ||
.normal_broadcast | ||
.clone() | ||
.verify::<SP>(verifier) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment as I made elsewhere: I find it a bit odd that a verify
method consumes self
. It should be a read-only operation.
Maybe we can inline the code from verify
and end up with something like
let digest = self.normal_broadcast.message_with_metadata.digest::<SP>()?;
let signature = self.normal_broadcast.signature.deserialize::<SP>()?;
verifier
.verify_digest(digest, &signature)
.map_err(MessageVerificationError::into_evidence_error)?;
let deserialized = self
.normal_broadcast
.payload()
.deserialize::<EchoRoundMessage<SP::Verifier>>(format)
.map_err(|error| {
EvidenceError::InvalidEvidence(format!("Failed to deserialize normal broadcast: {error:?}"))
})?;
self.error
.verify_evidence::<SP>(self.normal_broadcast.metadata(), &deserialized)
(broken code, just to illustrate)
artifacts: BTreeMap<Id, Self::Artifact>, | ||
) -> Result<FinalizeOutcome<Id, Self::Protocol>, LocalError>; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Round
now has awesome docs, much appreciated.
Main changes:
Round
now uses associated types for messages, payloads, and artifacts. Fixes TypedRound
trait #65. Most dynamic parts (BoxedFormat
,ProtocolMessagePart
, serialization/deserialization of messages etc) are hidden from the user.Protocol
to correspondingRound
implementors. This allows one to keep the related logic (receive_message()
and evidence verification) together.Corollary changes:
synedrion
#113 are rendered unnecessary (e.g. map-downcast). Need to re-evaluate after this PR is merged.verify_*_is_invalid
methods #105, fixes Simplify invalid message evidence verification #82 (it now happens inDynRound
impl forRound
types).AssociatedData
is renamed toSharedData
and is now an associated type ofProtocol
instead ofProtocolError
. This helps Uniformize associated data and entry point inputs #89, but does not close it yet.extend
combinator for protocols. #62 - it seems that given static rounds, an extension like that can be just as well implemented manually withoutmanul
support. Closes Add azip
combinator for protocols #61 for the same reason (plus the current system ofProtocol::round_info()
will make getting the correct round simply impossible in a zipped protocol)ProtocolError
mandatesDisplay
#114 (ProtocolError
now usesdescription()
instead ofDisplay
)BoxedRound::new_dynamic
to justnew
#112 (fix included in this PR)For reviewers
misbehave
was renamed toextend
(with the corresponding renames for types and traits), because it seemed to me like a better wording, but it is still intended for the same purpose: misbehavior tests. Should I bring back the original name?extend
is not really a combinator, it's more of a dynamic override API. I am not sure how useful it is outside tests. Should it be moved todev
?BoxedRound
is the only "boxed" part of the API visible to user. Is there a better name for it? (e.g.RoundInfo
is also "boxed", but doesn't have aBoxed
prefix) I don't think it can be hidden entirely as long as we allow finalizing into several different rounds.