diff --git a/radicle-cli/examples/rad-id-threshold-soft-fork.md b/radicle-cli/examples/rad-id-threshold-soft-fork.md new file mode 100644 index 00000000..7de2e9fb --- /dev/null +++ b/radicle-cli/examples/rad-id-threshold-soft-fork.md @@ -0,0 +1,48 @@ +In some cases, a peer can create references, which includes `rad/sigrefs`, +without having pushed the canonical default branch. For example, Bob can create +an issue in the repository: + +``` ~bob +$ rad issue open --title "Add Bob as a delegate" --description "We agreed to add me as a delegate, so I am creating an issue to track that work" --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji +╭──────────────────────────────────────────────────────────────╮ +│ Title Add Bob as a delegate │ +│ Issue f12d512c51d30429f7916db038ae0360e2e938c2 │ +│ Author bob (you) │ +│ Status open │ +│ │ +│ We agreed to add me as a delegate, so I am creating an issue │ +│ to track that work │ +╰──────────────────────────────────────────────────────────────╯ +✓ Synced with 1 node(s) +``` + +and if we inspect Alice's references, then we will see the following: + +``` ~alice +$ rad inspect --refs +z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi +└── refs +    ├── cobs +    │   └── xyz.radicle.id +    │      └── 0656c217f917c3e06234771e9ecae53aba5e173e +    ├── heads +    │   └── master +    └── rad +       ├── id +       └── sigrefs +z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk +└── refs +    ├── cobs +    │   └── xyz.radicle.issue +    │      └── f12d512c51d30429f7916db038ae0360e2e938c2 +    └── rad +       └── sigrefs +``` + +Despite not having the canonical branch, Alice should still be able to add Bob +as a delegate, since a threshold of 1 can still be reached: + +``` ~alice +$ rad id update --title "Add Bob" --description "Add Bob as a delegate" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q +7be665f9fccba97abb21b2fa85a6fd3181c72858 +``` diff --git a/radicle-cli/examples/rad-id-threshold.md b/radicle-cli/examples/rad-id-threshold.md index 9682ce0e..5e41a115 100644 --- a/radicle-cli/examples/rad-id-threshold.md +++ b/radicle-cli/examples/rad-id-threshold.md @@ -8,9 +8,9 @@ can be updated. ``` ~alice (fail) $ rad id update --title "Add Bob" --description "Add Bob as a delegate" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --threshold 2 ✗ Error: failed to verify delegates for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji -✗ Error: a threshold of 2 delegates cannot be met, found 1 delegate(s) and the following delegates are missing [did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk] +✗ Error: the threshold of 2 delegates cannot be met.. +✗ Error: the delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk is missing ✗ Hint: run `rad follow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk` to follow this missing peer -✗ Hint: run `rad sync -f` to attempt to fetch the newly followed peers ✗ Error: fatal: refusing to update identity document ``` diff --git a/radicle-cli/src/commands/id.rs b/radicle-cli/src/commands/id.rs index 617a5544..968f9867 100644 --- a/radicle-cli/src/commands/id.rs +++ b/radicle-cli/src/commands/id.rs @@ -441,6 +441,10 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { if let Some(errs) = verify_delegates(&proposal, &repo)? { term::error(format!("failed to verify delegates for {rid}")); + term::error(format!( + "the threshold of {} delegates cannot be met..", + proposal.threshold + )); for e in errs { e.print(); } @@ -761,15 +765,14 @@ fn print_diff( Ok(()) } +#[derive(Clone)] enum VerificationError { MissingDefaultBranch { branch: radicle::git::RefString, did: Did, }, - InsufficientDelegates { - threshold: usize, - met: usize, - missing: Vec, + MissingDelegate { + did: Did, }, } @@ -781,21 +784,11 @@ impl VerificationError { term::format::secondary(branch), term::format::did(did) )), - VerificationError::InsufficientDelegates { - threshold, - met, - missing, - } => { - term::error(format!( - "a threshold of {threshold} delegates cannot be met, found {met} delegate(s) and the following delegates are missing [{}]", - missing.iter().map(|did| did.to_string()).collect::>().join(","), + VerificationError::MissingDelegate { did } => { + term::error(format!("the delegate {did} is missing")); + term::hint(format!( + "run `rad follow {did}` to follow this missing peer" )); - for did in missing { - term::hint(format!( - "run `rad follow {did}` to follow this missing peer" - )); - } - term::hint("run `rad sync -f` to attempt to fetch the newly followed peers") } } } @@ -811,33 +804,23 @@ where let dids = &proposal.delegates; let threshold = proposal.threshold; let (canonical, _) = repo.canonical_head()?; - let mut errors = Vec::with_capacity(dids.len()); - let mut local_delegates = 0; let mut missing = Vec::with_capacity(dids.len()); + for did in dids { match refs::SignedRefsAt::load((*did).into(), repo)? { None => { - missing.push(*did); + missing.push(VerificationError::MissingDelegate { did: *did }); } Some(refs::SignedRefsAt { sigrefs, .. }) => { if sigrefs.get(&canonical).is_none() { - errors.push(VerificationError::MissingDefaultBranch { + missing.push(VerificationError::MissingDefaultBranch { branch: canonical.to_ref_string(), did: *did, - }) - } else { - local_delegates += 1; + }); } } } } - if local_delegates < threshold { - errors.push(VerificationError::InsufficientDelegates { - threshold, - met: local_delegates, - missing, - }); - } - Ok((!errors.is_empty()).then_some(errors)) + Ok((dids.len() - missing.len() < threshold).then_some(missing)) } diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index 71bad40f..13067a3b 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -452,6 +452,56 @@ fn rad_id_threshold() { .unwrap(); } +#[test] +fn rad_id_threshold_soft_fork() { + let mut environment = Environment::new(); + let alice = environment.node(config::node("alice")); + let bob = environment.node(config::node("bob")); + let working = tempfile::tempdir().unwrap(); + let working = working.path(); + let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap(); + + // Setup a test repository. + fixtures::repository(working.join("alice")); + + test( + "examples/rad-init.md", + working.join("alice"), + Some(&alice.home), + [], + ) + .unwrap(); + + let mut alice = alice.spawn(); + let mut bob = bob.spawn(); + + let events = bob.handle.events(); + bob.handle.seed(acme, Scope::All).unwrap(); + alice.connect(&bob).converge([&bob]); + + events + .wait( + |e| matches!(e, Event::RefsFetched { .. }).then_some(()), + time::Duration::from_secs(6), + ) + .unwrap(); + + formula(&environment.tmp(), "examples/rad-id-threshold-soft-fork.md") + .unwrap() + .home( + "alice", + working.join("alice"), + [("RAD_HOME", alice.home.path().display())], + ) + .home( + "bob", + working.join("bob"), + [("RAD_HOME", bob.home.path().display())], + ) + .run() + .unwrap(); +} + #[test] fn rad_id_update_delete_field() { let mut environment = Environment::new();