Pair Open Challenges into Pairings (kinds 3418 / 3419) for
Nostr: a game-agnostic matchmaking
primitive for turn-based, two-player abstract strategy board games of the chess
family.
Status — proposed NIP. The kinds
3418(Open Challenge) and3419(Pairing) belong to a NIP suite that is still a draft. The kind numbers and wire format may change. Pin an exact version and review the suite before relying on it in production.
A player enters a matchmaking pool by publishing a signed Open Challenge
(kind 3418) that names a matchmaker and exactly one timing designation —
a timestamper (attested mode) or one timing_relay (self-timed, the default) —
and carries the session terms (game, the rules reference to a Rule System
event of kind 3417, per-role variant preferences, time control, opponent
filter) — but no opponent. A designated matchmaker pairs two compatible
Open Challenges by publishing a Pairing (kind 3419), without any
acceptance signature from the players: their consent is pre-committed in their
Open Challenges, and a Pairing is binding only if it respects both. The Pairing
also carries what the matchmaker resolves — both players' variants, a drawn
seat per player, and a founding window — and either player then founds the
session by publishing a Game Session (kind 3422). The suite designates no
arbiter (ADR-0033).
This crate implements the primitive only, and is game-agnostic: it does not know any game's variant vocabulary, performs no I/O, draws no random number, never fetches or runs a rule system's module, and is silent on which game, rule system or parties an application designates. Those are a higher layer's concern (for example a matchmaker service that reads the relay, resolves the Rule System events, draws the seats, and signs Pairings).
kind 3417 event ──parse──▶ RuleSystem ───┐
▼ check_rule_system (each challenge)
kind 3418 event ──parse──▶ OpenChallenge ─┐
kind 3418 event ──parse──▶ OpenChallenge ─┴─evaluate(facts)─▶ Compatible{variants}
│
Resolution{variants, seat, found_until}
│
PairingBuilder ──▶ kind 3419
open_challenge—OpenChallenge::parseturns a kind-3418event into a typed, validated value (the event-local semantic constraints, decidable from the event alone);check_rule_systemis the cross-event half of therulesconstraint, once the referenced Rule System event is held.rule_system—RuleSystem::parseturns a kind-3417event into a typed value (the module's digest and ABI, the game, the retrieval URLs, the documentaryspecandsource), under the NIP's structural constraints.compatibility—evaluate(a, b, facts)decides whether two Open Challenges can be paired (common matchmaker, identical timing designation, common game, identical time control, the samerulesreference, satisfiable variants, and each player satisfying the other's filter), and resolves each player's variant.pairing—PairingBuilderlays a compatible pair out, under the matchmaker'sResolution(both variants, the seat draw,found_until), as an unsigned PairingEventBuilderfor the matchmaker to sign.
use nostchmaker::compatibility::{evaluate, Compatibility, Facts, RatingPool};
use nostchmaker::open_challenge::{OpenChallenge, RatingKind};
use nostchmaker::pairing::{PairingBuilder, Resolution, Seat};
use nostr::key::PublicKey;
// The consumer resolves the external facts the filters need, anchored at the
// Pairing's canonical timing: the contact list as of the anchor, and the most
// recent attestation by the *pinned* authority (under the pinned kind) with
// created_at at or before the anchor — in the pool the filter's declared
// scope selects (decision M-9). `everyone`-filtered pools need none of this.
struct MyFacts;
impl Facts for MyFacts {
fn follows(&self, _follower: &PublicKey, _target: &PublicKey) -> bool { false }
fn rating_within(
&self,
_authority: &PublicKey,
_kind: RatingKind,
_pool: RatingPool<'_>,
_a: &PublicKey,
_b: &PublicKey,
_max_delta: u16,
) -> bool { false }
}
// `a` and `b` are two parsed kind-3418 events (OpenChallenge::parse), each
// already checked against the Rule System event its `rules` reference names
// (`a.check_rule_system(&rule_system)`, after fetching kind 3417 by id);
// `a_first` is the matchmaker's fair coin, `now` its clock.
fn pair(a: &OpenChallenge, b: &OpenChallenge, a_first: bool, now: u64) {
if let Compatibility::Compatible { a_variant, b_variant } = evaluate(a, b, &MyFacts) {
// A variant left free (None) is the matchmaker's choice, from the
// vocabulary of the rule system both entries name (what its module's
// `describe` reports — not this crate's knowledge).
let a_variant = a_variant.unwrap_or_else(|| "chess".to_string());
let b_variant = b_variant.unwrap_or_else(|| "chess".to_string());
let resolution = Resolution {
a_variant: &a_variant,
b_variant: &b_variant,
a_seat: if a_first { Seat::First } else { Seat::Second },
found_until: now + 120, // the founding window
};
let _pairing = PairingBuilder::new(a, b, resolution)
.rules_hint("wss://relay.example.com")
.to_event_builder(); // sign with the matchmaker
}
}evaluate encodes the consent constraints of kind 3419 that are decidable
from the two Open Challenges plus the resolved facts: distinct signers, a common
matchmaker, an identical timing designation (the same timestamper, or the same
timing_relay), a common game, an identical time control, the same rules
reference (the rule system is a matching term the matchmaker cannot resolve),
a satisfiable variant resolution, and each player satisfying the other's
filter. A
following filter binds against the filterer's contact list; a rating filter
binds against the rating authority the filterer pins in their Open Challenge
(an authority pubkey plus the attestation kind, 3426 Elo or 3427 Glicko-2).
The comparison pool follows the filter's declared pool scope — its sixth
element, pergame or pervariant (decision M-9), so satisfaction is objective
from public data alone: under pervariant the filter is satisfiable only for a
same-variant pairing; under pergame (e.g. Sashité's sanki pools) it binds
across any variant combination, resolved or free.
It is deliberately silent on the rest, which a higher layer enforces:
- the accept-deadline (a timing decision anchored on the Pairing's canonical
timing — its timestamper attestation in attested mode, its own relay-enforced
created_atin self-timed mode); - the resolution of each
rulesreference — fetching the Rule System event by id and checking it (OpenChallenge::check_rule_system), and whatever the consumer requires of the module it names (kind3417); - the Pairing event's own shape (it is built by
PairingBuilder); - any operational policy such as a game allow-list, a rule-system allow-list or NIP-51 mute lists.
- No
unsafe(unsafe_code = "forbid"), no I/O, no clock access — pure functions. - The parsing path consumes untrusted events but only inspects fields that
nostrhas already parsed: it never reparses, never allocates on input size, and is total. The lint set denies panic-capable operations (unwrap/expect/panic/indexing/arithmetic). - Building is infallible.
- Supply chain is policed in CI by
cargo-deny(advisories, licenses, sources).
nostr 0.45. Developed and tested on Rust 1.96. See the status note near the
top of this document regarding the proposed NIP and the tentative kind numbers.
Licensed under the Apache License, Version 2.0. See NOTICE.