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
16 changes: 14 additions & 2 deletions pkgs/id/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ pub fn sign_claim(
/// final-state hash are `Agreed`; two claims that differ in either are
/// `Disputed`. A player claiming twice with different claims is a
/// fork of position and computes `Disputed`, never `Agreed`. Claims
/// answering a different agreement, or from non-players, are ignored.
/// answering a different agreement, from non-players, or with an outcome
/// other than `"draw"` or a seated player's lowercase 64-character hex id
/// are ignored.
#[must_use]
pub fn compute_state(
agreement: &GenesisAgreement,
Expand All @@ -236,10 +238,20 @@ pub fn compute_state(
let Ok(agreement_hash) = agreement.agreement_hash() else {
return MatchState::Proposed;
};
let player_outcomes = agreement.players.map(|player| {
player
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
});
let mut per_player: [Option<&ResultClaim>; 2] = [None, None];
for (index, player) in agreement.players.iter().enumerate() {
for (author, claim) in claims {
if author == player && claim.agreement == agreement_hash {
let valid_outcome = claim.outcome == "draw"
|| player_outcomes
.iter()
.any(|player_outcome| player_outcome == &claim.outcome);
if author == player && claim.agreement == agreement_hash && valid_outcome {
match per_player[index] {
None => per_player[index] = Some(claim),
Some(first) if first != claim => return MatchState::Disputed,
Expand Down
84 changes: 82 additions & 2 deletions pkgs/id/tests/attestation_poc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use p2panda_core::{Hash, SigningKey};
use tempfile::TempDir;

use id::attestation::{
author_id, compute_state, player_id, sign_claim, sign_genesis, GenesisAgreement, MatchState,
ResultClaim,
GenesisAgreement, MatchState, ResultClaim, author_id, compute_state, player_id, sign_claim,
sign_genesis,
};
use id::chain;
use id::event_store::{EventStore, Ingest};
Expand Down Expand Up @@ -114,6 +114,30 @@ fn play_match(x_outcome: &str, o_outcome: &str) -> Match {
}
}

fn decoded_objects(
played: &Match,
) -> (
Vec<(id::attestation::PlayerId, GenesisAgreement)>,
Vec<(id::attestation::PlayerId, ResultClaim)>,
) {
let genesis = played
.genesis
.iter()
.map(|(event, _)| (author_id(event), played.agreement.clone()))
.collect();
let claims = played
.claims
.iter()
.map(|(event, body)| (author_id(event), ResultClaim::from_body(body).unwrap()))
.collect();
(genesis, claims)
}

fn computed_state(played: &Match) -> MatchState {
let (genesis, claims) = decoded_objects(played);
compute_state(&played.agreement, &genesis, &claims)
}

/// Ingests every match event into a store, in chain order per author.
fn ingest_match(store: &mut EventStore, played: &Match) {
for (event, body) in played.genesis.iter().chain(played.claims.iter()) {
Expand Down Expand Up @@ -218,3 +242,59 @@ fn one_claim_only_stays_proposed() {
MatchState::Proposed
);
}

#[test]
fn out_of_domain_outcomes_are_ignored() {
let x = key(0);
let third_party = key(200);
let seat_id = hex(&player_id(&x));
let uppercase_seat_id = seat_id.to_uppercase();
assert_ne!(uppercase_seat_id, seat_id);

for outcome in [
hex(&player_id(&third_party)),
"banana".to_owned(),
uppercase_seat_id,
] {
let played = play_match(&outcome, &outcome);
assert_eq!(
computed_state(&played),
MatchState::Proposed,
"outcome: {outcome}"
);
}
}

#[test]
fn one_in_domain_and_one_out_of_domain_claim_stays_proposed() {
let x = key(0);
let played = play_match(&hex(&player_id(&x)), "banana");
assert_eq!(computed_state(&played), MatchState::Proposed);
}

#[test]
fn draw_is_a_valid_concurring_outcome() {
let played = play_match("draw", "draw");
assert_eq!(computed_state(&played), MatchState::Agreed);
}

#[test]
fn out_of_domain_claim_from_a_player_does_not_create_a_fork() {
let x = key(0);
let played = play_match(&hex(&player_id(&x)), &hex(&player_id(&x)));
let (genesis, mut claims) = decoded_objects(&played);
let valid_claim = claims
.iter()
.find(|(author, _)| author == &player_id(&x))
.expect("X's valid claim is present")
.1
.clone();
let mut invalid_claim = valid_claim;
invalid_claim.outcome = "banana".to_owned();
claims.push((player_id(&x), invalid_claim));

assert_eq!(
compute_state(&played.agreement, &genesis, &claims),
MatchState::Agreed,
);
}