Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 17 additions & 17 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ members = [
]

[workspace.package]
version = "0.3.59"
version = "0.3.60"
edition = "2024"
license = "MIT"
repository = "https://github.com/profullstack/c0mpute"
Expand Down
12 changes: 8 additions & 4 deletions dips/0034-agent-swarms.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ more challenges than endorsements; an owner reject always wins. A task is
task is done; a job is **complete** when its final task is done.

A bounty is **payable** when an owner accepted the result, or at least two
peers endorsed it and endorsements outnumber challenges.
reviewers with **standing** endorsed it and they outnumber challenges. A
reviewer has standing once someone other than itself has published
`task.paid` for one of its results in that swarm.

### Next action

Expand Down Expand Up @@ -148,9 +150,11 @@ then publishes `task.paid`. A result without a `payee` cannot be paid.

* Membership is the key. A leaked link cannot be revoked; make a new swarm.
* Gossip and relay peers see topic hashes, sizes and timing, not content.
* Sybil identities can endorse each other. That is why peer endorsement pays
only at two endorsers and more endorsements than challenges, and why owner
votes decide. Reputation from receipts is the next layer.
* Sybil identities can endorse each other. Their endorsements still finish
tasks, but only reviewers with standing (paid by someone else) make a
bounty payable, and owner votes decide. Standing can be bought by paying
your own sock puppets, which costs real money per identity; cross-swarm
reputation from receipts is the next layer.
* Posters can refuse to pay. `task.paid` makes payment public, and workers
skip lobby posters with unpaid accepted work.
* Lease griefing (claim and never submit) costs one lease; leases are capped
Expand Down
18 changes: 13 additions & 5 deletions docs/protocol/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ libp2p protocols, so any node can be dialed by peer id.
**Every node is a relay server.** A reservation it grants carries its
confirmed public addresses (from AutoNAT, or from `C0MPUTE_P2P_EXTERNAL`,
which a gateway with a fixed open port sets and which is confirmed at
start). A node behind NAT has none, and clients reject a reservation
without addresses, so in practice the public gateways and cloud workers
carry the relays.
start). A node with no confirmed external address refuses reservations
outright (a reservation rate limiter gated on it, since v0.3.60): before
that, nodes behind NAT or Docker granted empty reservations that clients
then rejected with `NoAddressesInReservation`. In practice the public
gateways and cloud workers carry the relays.

Server limits (raised from libp2p's defaults, which fit hole-punch
coordination only, so a job result can cross a relay when punching
Expand All @@ -43,8 +45,14 @@ confirmed us public, the node listens on
`<relay addr>/p2p/<relay>/p2p-circuit`, which asks the relay for a
reservation. It keeps at most two reservations, so losing one relay never
makes it unreachable. A relay that refuses (or later drops) a reservation
is left alone for 10 minutes. When AutoNAT later confirms the node public,
it gives its reservations back.
is left alone for 10 minutes, or 6 hours when it answered with no
addresses. When AutoNAT later confirms the node public, it gives its
reservations back.

Public is sticky for 30 minutes: after AutoNAT said Public, a Private
verdict starts relay reservations only once that grace has passed (one
probe server that cannot dial back, being behind NAT itself, used to flip
public nodes). A node with `C0MPUTE_P2P_EXTERNAL` set is always public.

The accepted reservation shows up as a listen address,

Expand Down
68 changes: 64 additions & 4 deletions node/crates/c0mpute-core/src/agent_swarm/reducer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
//! * A task is done while one of its results stands; a job is complete when
//! its final task is done.
//! * A bounty is payable when an owner accepted the result, or at least two
//! peers endorsed it and endorsements outnumber challenges.
//! reviewers with standing (someone else once paid them) endorsed it and
//! they outnumber challenges.

use std::collections::{BTreeMap, BTreeSet};

Expand Down Expand Up @@ -383,14 +384,30 @@ impl State {
}
}

/// Whether the job's bounty is owed for this result.
/// Whether `peer` has standing as a reviewer: someone other than itself
/// marked one of its results paid. Paying costs real money, so a swarm
/// of fresh identities endorsing each other has none.
pub fn has_standing(&self, peer: &str) -> bool {
self.submissions
.values()
.any(|s| s.author == peer && s.paid.as_ref().is_some_and(|p| p.by != peer))
}

/// Whether the job's bounty is owed for this result: an owner accepted
/// it, or at least two reviewers with standing endorsed it and they
/// outnumber every challenge.
pub fn payable(&self, sub: &Submission) -> bool {
match self.owner_votes(sub) {
(_, true) => false,
(true, false) => true,
(false, false) => {
let (e, c) = Self::tally(sub);
e >= 2 && e > c
let trusted = sub
.reviews
.iter()
.filter(|(who, r)| r.verdict == Verdict::Accept && self.has_standing(who))
.count();
let (_, c) = Self::tally(sub);
trusted >= 2 && trusted > c
}
}
}
Expand Down Expand Up @@ -750,6 +767,49 @@ mod tests {
assert!(State::build(&[job.clone(), close(&poster, 2)]).jobs[&job.id].closed);
}

#[test]
fn only_reviewers_with_standing_make_a_result_payable() {
let paid = |sub: &str, by: &libp2p_kp::Keypair, l| {
ev(by, SW, l, l, Body::TaskPaid(TaskPaid { submission: sub.into(), amount_usd: "1".into(), rail_ref: String::new() }))
};
let (poster, _) = signer();
let (w, _) = signer();
let (s1, _) = signer();
let (s2, _) = signer();
// Job 1: s1 and s2 each earn standing (paid by the poster).
let j1 = ev(&poster, SW, 1, 1, post(vec![("a", vec![]), ("b", vec![])], "1"));
let (a, b) = (make_task_id(&j1.id, "a"), make_task_id(&j1.id, "b"));
let sa = ev(&s1, SW, 3, 3, submit(&a, "x"));
let sb = ev(&s2, SW, 5, 5, submit(&b, "y"));
// Job 2: w's result endorsed by s1 and s2, no owner vote.
let j2 = ev(&poster, SW, 10, 10, post(vec![("c", vec![])], "1"));
let c = make_task_id(&j2.id, "c");
let sc = ev(&w, SW, 12, 12, submit(&c, "z"));
let mut log = vec![
j1.clone(), ev(&s1, SW, 2, 2, claim(&a)), sa.clone(), ev(&s2, SW, 4, 4, claim(&b)), sb.clone(),
j2.clone(), ev(&w, SW, 11, 11, claim(&c)), sc.clone(),
ev(&s1, SW, 13, 13, review(&sc.id, true)), ev(&s2, SW, 14, 14, review(&sc.id, true)),
];
let s = State::build(&log);
assert!(s.stands(&s.submissions[&sc.id]), "two endorsements finish the task");
assert!(!s.payable(&s.submissions[&sc.id]), "fresh identities cannot make it payable");
// Self-marked payment gives no standing.
log.push(paid(&sa.id, &s1, 15));
assert!(!State::build(&log).has_standing(&libp2p_kp::peer(&s1)));
// Paid by the poster: both reviewers gain standing; now payable.
log.push(paid(&sa.id, &poster, 16));
log.push(paid(&sb.id, &poster, 17));
let s = State::build(&log);
assert!(s.payable(&s.submissions[&sc.id]));
}

mod libp2p_kp {
pub use c0mpute_net::libp2p::identity::Keypair;
pub fn peer(k: &Keypair) -> String {
k.public().to_peer_id().to_string()
}
}

#[test]
fn unpaid_posters_are_visible() {
let (poster, pp) = signer();
Expand Down
Loading
Loading