The event graph (src/event_graph/) runs two DAG families: rotating hourly
DAGs carrying darkirc Privmsg traffic, and a single never-pruned static DAG
whose admission path (handle_static_put → rln_verify_static_event)
currently interprets every event's content as an RLN node when RLN is enabled,
and accepts anything structurally valid when RLN is disabled
(commit_static_event_unverified). The static DAG already syncs on every node,
including the app (which runs RLN disabled). Privmsg has no signature fields;
identity on the wire is limited to the (unauthenticated) nick. irc2
(bin/darkirc, embedded by bin/app) has a working IRC-service precedent in
NickServ, and chanserv is already a reserved nick in the relay path.
Goals:
Non-Goals:
Application payloads ride the existing static DAG. Content is discriminated by its first byte:
static content first byte:
0x00 | 0x01 → RLN payload (existing RLNNode encoding, unchanged)
0x02.. → application payload tag (registry below)
Rationale: a second EventGraph instance would collide on the fixed p2p message
type names and the hardcoded "static-dag" DAG name used by static_sync,
requiring invasive protocol namespacing for no benefit. The static DAG's
canonical (layer, event_id) ordering — which RLN historical-root consistency
depends on — is content-agnostic, so interleaving app events does not disturb
it. rebuild_rln_state_from_static skips app-tagged content deterministically.
Collision note: darkfi-serial encodes enum variants as u64 LE, so
RLNNode::Registration starts 0x00 and RLNNode::Slashing starts 0x01.
Reserving exactly those two first bytes for RLN is therefore precise today;
if RLNNode ever gains variants, its variant space and the app tag registry
must be reconciled in the same change (assert this in a test). The RLNNode
encoding itself is NOT changed: deployed static DAGs hold RLN events in this
form and they must keep parsing.
Version skew: pre-change nodes fail RLNNode deserialization on app-tagged
content (variant index reads as garbage ≥ 2) and skip it without penalty; they
do not relay it. Mixed-version networks are unsupported (see Migration Plan).
Darkirc rotating content gets a leading tag byte; relay paths dispatch on it instead of trial deserialization. This is a hard break of the chat wire format: there is no untagged form.
0x00 Privmsg (existing fields + optional signer pk + schnorr signature)
0x01 HideAction
0x02+ reserved for future types; unknown tags are skipped, not errors
The tag byte is the sole content-type discriminator; the Privmsg struct keeps
its internal fields (version, msg_type, channel, nick, msg) unchanged and
the signature fields are part of the Privmsg payload itself.
The tag identifies content type only. It must not distinguish encrypted-channel from encrypted-DM payloads: that distinction is metadata an observer of the public DAG must not get for free. Plaintext vs encrypted is already self-evident on the wire (plaintext is readable), so a plaintext tag leaks nothing new; the encrypted forms share one path and resolution between channel-key and DM-key decryption remains a local trial.
The static DAG provides replication but no total order. Ordering comes from a self-certifying per-channel chain:
REGISTER #chan {owner_pk} prev: ∅
└─► POLICY LIST [...] prev: <prev static event id> sig: owner_pk
└─► TRANSFER {pk_new} prev: ... sig: owner_pk
└─► POLICY / PIN prev: ... sig: pk_new
(layer, event_id) order as tie-break.Alternatives considered: last-writer-wins by DAG order (racy — two nodes see different orders and flip-flop); side-events for owner ops (unnecessary — owner ops are rare, and chains give transfer-of-authority for free).
Register, Transfer, PolicyList, Pin.
Authorization data (owner key, admin sets, allow-lists, filter params,
default-enabled flags) must be durable and ordered → chain.HideAction { channel, target_event_id, hidden, actor_pk,
sig }. Hides are frequent, need no ordering beyond last-wins-per-target in
canonical rotating order, and gain two properties by expiring with the
window: no permanent public record that an event existed/was hidden, and no
static-DAG churn. The chain's admin set authorizes them; an action signed by
a key outside the current (enabled) admin set resolves to nothing.Hide tombstones reference the DAG event id (stable, node-independent, available pre-decryption), not the client-side content msg_id (timestamp- correction hacks make it unstable). The hide check runs in the relay path where both ids are in hand, before msg_id conversion.
u8 ChanServ name params
0 ALLOWLIST Vec<pk> render only messages with valid sig from set
1 ADMINHIDE Vec<pk> keys may publish HideActions
2 FILTER rules regexes over the privmsg (nick and/or msg)
The wire format carries the u8; the ChanServ command surface accepts the
policy name (ALLOWLIST, ADMINHIDE, FILTER) and parses it to the enum
value, rejecting unknown names.
A PolicyList chain action replaces the whole list wholesale; each entry is
{policy: u8, params: Vec<u8>, enabled: bool}. Params are opaque bytes owned
by the built-in evaluator for that id; unknown ids are ignored at resolution
(forward compatibility, also the seam where user-installed policies land
later). The enum numbering is frozen at merge; adding policies appends.
FILTER evaluation happens on the decoded plaintext privmsg (after
channel/DM decryption for encrypted targets — same local trial as rendering).
Regex compilation from policy params is fallible: an uncompilable rule is
ignored and the remaining rules still apply (untrusted-input invariant: policy
params are owner-controlled but owners can be adversarial). The evaluator
uses a linear-time regex engine (Rust regex crate: finite-automata, no
catastrophic backtracking) so hostile patterns cannot wedge a client.
Note: regex is already a bin/app dependency but is a new dependency for
irc2 (bin/darkirc/Cargo.toml) — dependency addition requires human review
per repo policy.
The Privmsg payload keeps its existing field order and appends optional
signer_pk + sig. The signature is over the serialized core fields
(version | msg_type | channel | nick | msg), computed on plaintext before
any channel/DM encryption. For encrypted targets the sig fields are encrypted
along with the rest (nothing visible outside); for plaintext channels they are
public. Signatures do not cover the header timestamp — client-side timestamp
correction must not break verification, and content replays under a new event
id are indistinguishable from quotes, which IRC legitimately allows.
Posting in an allow-listed channel is opt-in linkability: the policy only makes sense where users accept a persistent per-channel signing key. This is stated UX, not a hidden cost.
Pin { target_event_id, snapshot } is an owner chain action. The snapshot
embeds the message content (nick, msg, original timestamp) because the pinned
event expires from the rotating window after 24h. For saltbox channels the
snapshot is encrypted under the channel key — the static DAG must never carry
plaintext of an encrypted channel. FUD icon links etc. are future chain action
kinds; the action enum is extensible.
handle_static_put's existing moving-window
flood guard covers the live path.static_sync currently requires a non-empty blob per non-genesis event (for
RLN proof re-verification). App-tagged events carry no proof: the blob
alignment check must treat empty blobs as valid for app tags. Structural
checks (parents present, content-hash match, size bound) are re-applied at
sync; signature/chain validity is resolved above the sync layer.Mirrors NickServ: NOTICE replies, HELP texts, command dispatch from
handle_cmd_privmsg on the reserved ChanServ nick. Authentication is
possession of the locally configured key: ChanServ signs with the config's
owner (or admin) key and refuses commands whose matching key is absent.
/msg ChanServ REGISTER #channel
/msg ChanServ INFO [#channel] owner, chain tip, policy list, pins
/msg ChanServ TRANSFER #channel <pk>
/msg ChanServ POLICY #channel LIST
/msg ChanServ POLICY #channel SET <name> <params> e.g. SET ADMINHIDE <pk1>,<pk2>
/msg ChanServ POLICY #channel DEFAULT <name> ON|OFF
/msg ChanServ PIN #channel <event_id>
/msg ChanServ UNPIN #channel <event_id>
/msg ChanServ HIDE #channel <event_id> (admin key)
/msg ChanServ UNHIDE #channel <event_id> (admin key)
/msg ChanServ HELP [command]
Usage examples with real values, one per policy type:
--- ALLOWLIST (policy 0): only signed messages from these keys render ---
/msg ChanServ POLICY #darkfi SET ALLOWLIST 9mkH5rwnYtV4JCvfH2N7yc6bT1eSQkWLDpGXzKR8uFq3,7dRm2cVbXwNZtPjLKe84TghY6FqsaU1JzCNoEWBkv5Py
/msg ChanServ POLICY #darkfi DEFAULT ALLOWLIST ON
→ the channel now defaults to rendering only messages signed by
one of the two listed keys; senders attach their pk + signature
--- ADMINHIDE (policy 1): these keys may hide/unhide messages ---
/msg ChanServ POLICY #darkfi SET ADMINHIDE FgYU9dV1K2vQmTHeqXPnWZuLycA4sBDr7EJk6atMZiWo
/msg ChanServ HIDE #darkfi 9a41c7e0d3b8f6521e0a4cd7b83f19e2c5a6d0b8f3e2714c9d5a8b6e3f0c2d17
→ the admin-key holder hides the spam event; clients mark it hidden
/msg ChanServ UNHIDE #darkfi 9a41c7e0d3b8f6521e0a4cd7b83f19e2c5a6d0b8f3e2714c9d5a8b6e3f0c2d17
→ restores it if hidden in error
--- FILTER (policy 2): rules matching nick and/or message content ---
/msg ChanServ POLICY #darkfi SET FILTER nick:^spam\w*$,nick:^ninja\d+$,msg:(?i)(airdrop|free coins|giveaway)
/msg ChanServ POLICY #darkfi DEFAULT FILTER ON
→ each rule is field:regex, comma-separated; nick rules match the
sender nick, msg rules match the message body; matching messages
are hidden for clients applying the policy
Public keys are base58-encoded pallas points (schnorr public keys); event ids
are the blake3 DAG event ids shown by INFO or by the future app
context-action. HIDE/UNHIDE take a pasted event id for now; the app later
adds a context-menu action emitting the same underlying flow.
Schnorr keypairs (pallas) configured as secrets in darkirc TOML (desktop) and the app settings store; public keys derived, never configured separately. Secrets are used only for local signing and are never logged or transmitted (hard invariant: no secret leakage).
In the app, entering a channel from the menu shows the chat screen with the
channel name (e.g. #dev) as a label at the top. That channel-name label
becomes tappable and opens the per-channel policy overlay:
chat screen overlay (modal layer)
┌───────────────────────────┐ ┌────────────────────────────────┐
│ [#dev] ← channel label │ tap │ #dev — channel policy │
│───────────────────────────│ ───► │ ──────────────────────────────│
│ 12:01 <alice> hey all │ │ [x] AllowList (default) │
│ 12:02 <bob> ... │ │ [x] AdminHide (default) │
│ ... │ │ [ ] FILTER (default) │
└───────────────────────────┘ │ overridden locally: ON │
└────────────────────────────────┘
What changes in the app:
bin/app/src/app/schema/chat.rs): the chat
screen already places the channel-name label at fixed coordinates
(CHANNEL_LABEL_X/CHANNEL_LABEL_Y and friends). The tap target is a
normal button node placed on top of that existing label — same button
pattern the chat screen already uses for send/emoji buttons — whose
activation opens the policy overlay for the displayed channel. No changes
to the chatview text rendering or hit-testing are required.default || override resolution: the overlay shows
both the owner default and the user's effective state.handle_static_put, static_sync,
rebuild) is security-critical] → RLN branches stay byte-identical; new code
is additive dispatch on first byte; dedicated tests assert RLN behavior
unchanged; human review on the diff (per repo policy).Hard break of the darkirc chat wire format; no compat shims and no legacy
untagged Privmsg. Deploy: all darkirc nodes (desktop + app) upgrade in one
coordinated step. Existing untagged messages in rotating DAGs become
unreadable but age out permanently within one rotation window (≤24h) — no
data migration, the network self-cleans. Static-DAG RLN history remains valid
(the RLNNode encoding is untouched); application static payloads are new and
only exist post-upgrade. Rollback = redeploy pre-change binaries and let the
window drain again. After merge, run make (proofs/contracts unaffected) and
make test.
UNHIDE is a separate command or HIDE with a flag (command
surface only; resolution semantics already fixed as last-wins-per-target).