Skip to content

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

Open
wants to merge 29 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9edb13a
Relax Id bounds on traits
fjarri Jun 15, 2025
e93bbfc
Add `StaticRound` to eliminate some boilerplate when writing protocols
fjarri Jun 15, 2025
e8d23b1
Add `Protocol::round_info()` routing method
fjarri Jun 15, 2025
11620e5
Add more methods
fjarri Jun 16, 2025
fa2028b
Fix chain combinator
fjarri Jun 18, 2025
c172784
Replace Misbehave with Extend
fjarri Jun 24, 2025
959da23
Splitting error processing between rounds
fjarri Jun 25, 2025
5ac9729
Fully switch to statically typed rounds
fjarri Jun 27, 2025
a2ab642
Separate round and group number types
fjarri Jul 14, 2025
9ff447c
Ungroup messages correctly for chained protocol errors
fjarri Jul 18, 2025
32499ef
Merge dyn_protocol into protocol
fjarri Jul 18, 2025
8659991
Rename Extension methods
fjarri Jul 18, 2025
7b742a4
Add a TODO
fjarri Jul 18, 2025
6952e51
Add a changelog
fjarri Jul 18, 2025
e8d2fca
Add the round number to the evidence description
fjarri Jul 28, 2025
fb07279
Update obsolete docstrings
fjarri Jul 28, 2025
5757b16
Remove an implicit cast
fjarri Jul 28, 2025
1a05741
Fix an artifact type in dining_cryptographers
fjarri Jul 28, 2025
fe8e2b8
Run dining_cryptographers in CI
fjarri Jul 28, 2025
bc066c6
Rename `into_boxed()` -> `into_inner()`
fjarri Jul 28, 2025
247ea03
Remove unneeded (crate) specifiers
fjarri Jul 28, 2025
af3d288
Add some comments for `ExtendedRound` fields
fjarri Jul 28, 2025
e6856a5
Fix `extend` docstring
fjarri Jul 28, 2025
56fbe74
Use a more idiomatic method
fjarri Jul 28, 2025
9df80d0
More descriptive error message in `ungroup()`
fjarri Jul 28, 2025
6edb7b0
Update the term in `chain` docstring
fjarri Jul 28, 2025
e2a2e3e
Reword `Protocol::SharedData` docstring
fjarri Jul 28, 2025
d85e47b
Rename ProtocolMessage -> DynProtocolMessage
fjarri Jul 28, 2025
81e411c
Rename MessageParts -> ProtocolMessage
fjarri Jul 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ jobs:
files: lcov.info
fail_on_error: true

examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@v1
with:
profile: minimal
toolchain: stable
target: x86_64-unknown-linux-gnu
override: true
- name: Run `dining_cryptographers`
run: cargo run dining_cryptographers

# This is supposed to factor out possible bugs introduced by Rust compiler changes
# and dependency changes, making the results more reproducible.
stable-test:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- `session::tokio::run_session()` and `par_run_session()` take an additional `cancellation` argument to support external loop cancellation. ([#100])
- `Round` now uses associated types for messages, payloads, and artifacts instead of boxed types. ([#117])
- Protocol errors and evidence verification are now defined for each round separately. `ProtocolError` is an associated type of `Round`. ([#117])
- `misbehave` combinator is reworked ino `extend`. It now works by defining typed extensions for a specific `Round` type. ([#117])


### Fixed
Expand All @@ -17,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0


[#100]: https://github.com/entropyxyz/manul/pull/100
[#117]: https://github.com/entropyxyz/manul/pull/117
[#119]: https://github.com/entropyxyz/manul/pull/119


Expand Down
39 changes: 39 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 52 additions & 76 deletions examples/dining_cryptographers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ use std::collections::{BTreeMap, BTreeSet};
use manul::{
dev::{run_sync, BinaryFormat, TestHasher, TestSignature, TestSigner, TestVerifier},
protocol::{
Artifact, BoxedFormat, BoxedRound, CommunicationInfo, DirectMessage, EchoBroadcast, EchoRoundParticipation,
EntryPoint, FinalizeOutcome, LocalError, MessageValidationError, NoProtocolErrors, NormalBroadcast, Payload,
Protocol, ProtocolMessage, ProtocolMessagePart, ReceiveError, Round, RoundId, TransitionInfo,
BoxedRound, CommunicationInfo, EchoRoundParticipation, EntryPoint, FinalizeOutcome, LocalError, NoArtifact,
NoMessage, NoProtocolErrors, Protocol, ProtocolMessage, ReceiveError, Round, RoundId, RoundInfo,
TransitionInfo,
},
session::SessionParameters,
};
Expand All @@ -73,35 +73,18 @@ use tracing::{debug, info, trace};
#[derive(Debug)]
pub struct DiningCryptographersProtocol;

impl<Id> Protocol<Id> for DiningCryptographersProtocol {
impl Protocol<DinerId> for DiningCryptographersProtocol {
// XOR/¬XOR of the two bits of each of the three diners (one is their own cointoss, the other shared with their
// neighbour).
type Result = (bool, bool, bool);
type SharedData = ();

type ProtocolError = NoProtocolErrors;

fn verify_direct_message_is_invalid(
_format: &BoxedFormat,
_round_id: &RoundId,
_message: &DirectMessage,
) -> Result<(), MessageValidationError> {
Ok(())
}

fn verify_echo_broadcast_is_invalid(
_format: &BoxedFormat,
_round_id: &RoundId,
_message: &EchoBroadcast,
) -> Result<(), MessageValidationError> {
Ok(())
}

fn verify_normal_broadcast_is_invalid(
_format: &BoxedFormat,
_round_id: &RoundId,
_message: &NormalBroadcast,
) -> Result<(), MessageValidationError> {
Ok(())
fn round_info(round_id: &RoundId) -> Option<RoundInfo<DinerId, Self>> {
match round_id {
_ if round_id == 1 => Some(RoundInfo::new::<Round1>()),
Copy link
Collaborator

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.

  1. 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
        }
    }
  1. Instead of using the PartialEq impl, add a round() method to RoundId 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,
        }
    }
  1. Same as 2 but adding a to_info() method on the Round 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.

Copy link
Member Author

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.

Copy link
Member Author

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.

Copy link
Member Author

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.

_ if round_id == 2 => Some(RoundInfo::new::<Round2>()),
_ => None,
}
}
}

Expand All @@ -125,6 +108,14 @@ pub struct Round2 {

impl Round<DinerId> for Round1 {
type Protocol = DiningCryptographersProtocol;
type ProtocolError = NoProtocolErrors<Self>;

type DirectMessage = Round1Message;
type EchoBroadcast = NoMessage;
type NormalBroadcast = NoMessage;

type Payload = bool;
type Artifact = ();

// Used to define the possible paths to and from this round. This protocol is very simple, it's simply Round 1 ->
// Round 2, so we can use the "linear" utility method to set this up.
Expand Down Expand Up @@ -157,44 +148,37 @@ impl Round<DinerId> for Round1 {
// This is called when this diner prepares to share a random bit with their neighbour.
fn make_direct_message(
&self,
_rng: &mut dyn CryptoRngCore,
format: &BoxedFormat,
_rng: &mut impl CryptoRngCore,
destination: &DinerId,
) -> Result<(DirectMessage, Option<Artifact>), LocalError> {
) -> Result<(Self::DirectMessage, Self::Artifact), LocalError> {
info!(
"[Round1, make_direct_message] from {:?} to {destination:?}",
self.diner_id
);
let msg = Round1Message { toss: self.own_toss };
let dm = DirectMessage::new(format, msg)?;

Ok((dm, None))
Ok((Round1Message { toss: self.own_toss }, ()))
}

// This is called when this diner receives a bit from their neighbour.
fn receive_message(
&self,
format: &BoxedFormat,
from: &DinerId,
message: ProtocolMessage,
) -> Result<Payload, ReceiveError<DinerId, Self::Protocol>> {
let dm = message.direct_message.deserialize::<Round1Message>(format)?;
message: ProtocolMessage<DinerId, Self>,
) -> Result<Self::Payload, ReceiveError<DinerId, Self>> {
let dm = message.direct_message;
debug!(
"[Round1, receive_message] {:?} was dm'd by {from:?}: {dm:?}",
self.diner_id
);
let payload = Payload::new(dm.toss);
Ok(payload)
Ok(dm.toss)
}

// At the end of round 1 we construct the next one, Round 2, and return a [`FinalizeOutcome::AnotherRound`].
fn finalize(
self: Box<Self>,
_rng: &mut dyn CryptoRngCore,
payloads: BTreeMap<DinerId, Payload>,
_artifacts: BTreeMap<DinerId, Artifact>,
self,
_rng: &mut impl CryptoRngCore,
payloads: BTreeMap<DinerId, Self::Payload>,
_artifacts: BTreeMap<DinerId, Self::Artifact>,
) -> Result<FinalizeOutcome<DinerId, Self::Protocol>, LocalError> {
let payloads = downcast_payloads::<bool>(payloads)?;
debug!("[Round1, finalize] {:?} sees payloads: {payloads:?}", self.diner_id);

let neighbour_toss = *payloads
Expand All @@ -206,7 +190,7 @@ impl Round<DinerId> for Round1 {
"[Round1, finalize] {:?} is finalizing to Round 2. Own cointoss: {}, neighbour cointoss: {neighbour_toss}",
self.diner_id, self.own_toss
);
Ok(FinalizeOutcome::AnotherRound(BoxedRound::new_dynamic(Round2 {
Ok(FinalizeOutcome::AnotherRound(BoxedRound::new(Round2 {
diner_id: self.diner_id,
own_toss: self.own_toss,
neighbour_toss,
Expand All @@ -217,6 +201,14 @@ impl Round<DinerId> for Round1 {

impl Round<DinerId> for Round2 {
type Protocol = DiningCryptographersProtocol;
type ProtocolError = NoProtocolErrors<Self>;
Copy link
Collaborator

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?

Copy link
Collaborator

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.

Copy link
Member Author

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?

Copy link
Collaborator

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?

Copy link
Collaborator

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.

Copy link
Collaborator

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.


type DirectMessage = NoMessage;
type EchoBroadcast = NoMessage;
type NormalBroadcast = Round2Message;

type Payload = bool;
type Artifact = NoArtifact;

// This round is the last in the protocol so we can terminate here.
fn transition_info(&self) -> TransitionInfo {
Expand Down Expand Up @@ -247,11 +239,7 @@ impl Round<DinerId> for Round2 {
}

// Implementing this method means that Round 2 will make a broadcast (without echoes).
fn make_normal_broadcast(
&self,
_rng: &mut dyn CryptoRngCore,
format: &BoxedFormat,
) -> Result<NormalBroadcast, LocalError> {
fn make_normal_broadcast(&self, _rng: &mut impl CryptoRngCore) -> Result<Self::NormalBroadcast, LocalError> {
debug!(
"[Round2, make_normal_broadcast] {:?} broadcasts to everyone else",
self.diner_id
Expand All @@ -262,37 +250,33 @@ impl Round<DinerId> for Round2 {
} else {
self.own_toss ^ self.neighbour_toss
};
let msg = Round2Message { reveal };
let bcast = NormalBroadcast::new(format, msg)?;
Ok(bcast)
Ok(Round2Message { reveal })
}

// Called once for each diner as messages are delivered to it. Here we deserialize the message using the configured
// [`SessionParameters::WireFormat`] and construct the [`Payload`] that we want to make available to the `finalize`
// method below.
fn receive_message(
&self,
format: &BoxedFormat,
from: &DinerId,
message: ProtocolMessage,
) -> Result<Payload, ReceiveError<DinerId, Self::Protocol>> {
message: ProtocolMessage<DinerId, Self>,
) -> Result<Self::Payload, ReceiveError<DinerId, Self>> {
debug!("[Round2, receive_message] from {from:?} to {:?}", self.diner_id);
let bcast = message.normal_broadcast.deserialize::<Round2Message>(format)?;
let bcast = message.normal_broadcast;
trace!("[Round2, receive_message] message (deserialized bcast): {:?}", bcast);
// The payload is kept and delivered in the `finalize` method.
let payload = Payload::new(bcast.reveal);
Ok(payload)
Ok(bcast.reveal)
}

// The `finalize` method has access to all the [`Payload`]s that were sent to this diner. This protocol does not use
// [`Artifact`]s, but when used, they are also available here.
// This is the last round in the protocol, so we return a [`FinalizeOutcome::Result`] with the result of the
// protocol from this participant's point of view.
fn finalize(
self: Box<Self>,
_rng: &mut dyn CryptoRngCore,
payloads: BTreeMap<DinerId, Payload>,
_artifacts: BTreeMap<DinerId, Artifact>,
self,
_rng: &mut impl CryptoRngCore,
payloads: BTreeMap<DinerId, Self::Payload>,
_artifacts: BTreeMap<DinerId, Self::Artifact>,
) -> Result<FinalizeOutcome<DinerId, Self::Protocol>, LocalError> {
// XOR/¬XOR the two bits of this diner, depending on whether they paid or not.
let mut own_reveal = self.own_toss ^ self.neighbour_toss;
Expand All @@ -301,8 +285,7 @@ impl Round<DinerId> for Round2 {
}
// Extract the payloads from the other participants so we can produce a [`Protocol::Result`]. In this case it is
// a tuple of 3 booleans.
let payloads_d = downcast_payloads::<bool>(payloads)?;
let bits = payloads_d.values().cloned().collect::<Vec<_>>();
let bits = payloads.into_values().collect::<Vec<_>>();
Ok(FinalizeOutcome::Result((bits[0], bits[1], own_reveal)))
}
}
Expand Down Expand Up @@ -337,7 +320,7 @@ impl EntryPoint<DinerId> for DiningEntryPoint {
// Each `EntryPoint` creates one `Session`.
fn make_round(
self,
rng: &mut dyn CryptoRngCore,
rng: &mut impl CryptoRngCore,
_shared_randomness: &[u8],
id: &DinerId,
) -> Result<BoxedRound<DinerId, Self::Protocol>, LocalError> {
Expand All @@ -351,7 +334,7 @@ impl EntryPoint<DinerId> for DiningEntryPoint {
"[DiningEntryPoint, make_round] diner {id:?} tossed: {:?} (paid? {paid})",
round.own_toss
);
let round = BoxedRound::new_dynamic(round);
let round = BoxedRound::new(round);
Ok(round)
}
}
Expand All @@ -376,13 +359,6 @@ impl SessionParameters for DiningSessionParams {
type WireFormat = BinaryFormat;
}

// Just a utility method to help us convert a [`Payload`] to, for example, a `bool`.
fn downcast_payloads<T: 'static>(map: BTreeMap<DinerId, Payload>) -> Result<BTreeMap<DinerId, T>, LocalError> {
map.into_iter()
.map(|(id, payload)| payload.downcast::<T>().map(|p| (id, p)))
.collect()
}

fn main() {
tracing_subscriber::fmt::init();
info!("Dining Cryptographers Protocol Example");
Expand Down
2 changes: 1 addition & 1 deletion examples/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ pub mod simple;
pub mod simple_chain;

#[cfg(test)]
mod simple_malicious;
mod simple_test;
Loading
Loading