Plenum

Backend Implementation Plan
(Parallelizable workstreams for the Monero FROST multisig wallet)

Internal working document · June 2026 · companion to plenum_idea.md

This is the engineering plan for Plenum's backend. The whitepaper (plenum_idea.md) states what Plenum is and why. This states how the backend is built, decomposed into workstreams that can be built in parallel by separate developers or agents, with the interface contracts that make that parallelism possible.

Scope: backend only. The user interface (desktop GUI, later Android) is a separate plan and is out of scope here. The headless daemon and its REST and MCP surfaces are backend and are covered (W8); the UI consumes that daemon but is not designed here.

Fixed inputs from the whitepaper: Monero only, FROST threshold signing (target: the post-fork FCMP++/CARROT path, see A1), MLS over SimpleX SMP queues reached through Tor or Nym, seed as single backup root (the per-group MLS identity and FROST share ride inside the blob, neither seed-derived), one encrypted backup blob replicated three ways, membership change as a fresh group.

Grounded in three reference codebases: monero-oxide (the pure-Rust Monero stack, formerly monero-serai) with modular-frost/dkg from serai-dex, the eigenwallet wallet (formerly UnstoppableSwap), and the official monero-project/monero wallet2 C++ engine.


Contents


Part A. Framing

A1. The strategic target and the SpendAuthority boundary

Monero's privacy layer is mid-transition. Mainnet today (June 2026) is still CLSAG ring signatures plus Bulletproofs+ over RingCT (consensus v16). A hard fork introducing FCMP++ (full-chain membership proofs) with CARROT (a new addressing and spend-authorization scheme) is in development but not yet on mainnet: as of June 2026 the hard-fork milestone was about 41 percent complete, the membership-proof circuit audits had not started, and no firm activation date is published. Realistic mainnet activation is late 2026 to 2027. FCMP++ removes ring signatures and CLSAG, changes key-image and output authorization, and was designed to carry a clean two-round FROST-style threshold scheme over its Generalized Schnorr Protocol (GSP). CARROT changes address derivation and scanning.

Decision (2026-06-10): target the post-fork FCMP++/CARROT path, not CLSAG. Plenum is long-lived and will not ship a transacting wallet for many months. Rather than invest heavily in CLSAG threshold signing that FCMP++ supersedes, the plan is: build and polish the clients first to generate public interest (the UI is a separate plan), build the proof-system-independent backend in parallel, and defer the on-chain signing core until FCMP++ is clean (audited and live on mainnet), then target its FROST-GSP threshold scheme. The CLSAG material in W1, W2, and the key-image discussion is retained as the reference design and an interim fallback, because (a) CLSAG is what works on mainnet if FCMP++ slips, (b) the surrounding mechanics (the DKG, deterministic build, channel, backup, store) carry over unchanged, and (c) the threshold-CLSAG path is now audited (Cypher Stack May 2025 plus the FROSTLASS formalization and proofs), so it is a usable fallback rather than dead weight.

The mechanism that makes this affordable is one trait boundary, SpendAuthority, behind which all proof-system-specific logic lives: transaction construction, the threshold signing session, key-image assembly, and address derivation. The DKG, the channel, the transport, the backup model, the store, and the daemon do not know which proof system is in use. Swapping CLSAG for FCMP++/GSP changes only the W1 and W2 implementations behind SpendAuthority; everything else is untouched. monero-oxide tracks the fork; Plenum follows its release.

This boundary is also a parallelization win: it isolates the only code that waits on FCMP++ into two workstreams and keeps the other seven, plus the clients, moving now.

A2. Dependency and license map

Plenum is GPL-3.0. Every cryptographic building block it links is MIT, which is GPL-3.0 compatible; the Nym transport SDK it links is itself GPL-3.0, which is what makes the project license GPL (A2). The only AGPL code in the reference set is serai's processor and coordinator, which Plenum does not link; they are read as a blueprint only.

Component Crate(s) Source License Used by
Monero protocol and tx monero-oxide, monero-wallet, monero-clsag, monero-address monero-oxide org (was monero-serai) MIT W1
Monero RPC client monero-interface, monero-simple-request-rpc monero-oxide org MIT W7
FROST engine modular-frost (0.11) serai-dex crypto/frost MIT W2
DKG dkg, dkg-pedpop (0.6) serai-dex crypto/dkg MIT W2
Supporting crypto dleq, schnorr, ciphersuite, dalek-ff-group, multiexp, flexible-transcript serai-dex crypto/* MIT transitive
MLS OpenMLS independent MIT/Apache W3
Anonymity transport arti-client (Arti), Nym SDK independent Tor: MIT/Apache, Nym: GPL W4
Serai processor/coordinator serai-processor serai-dex processor/ AGPL-3.0 blueprint only, not linked

Two naming traps. First, monero-serai was renamed monero-oxide in late 2025 (stewarded by boog900 of Cuprate and kayabaNerve of Serai); the whitepaper still names the lineage monero-serai, which is fine, but Cargo.toml pins monero-oxide. Second, "monero-wallet" is overloaded: there is the monero-oxide library crate monero-wallet and various same-named workspace crates elsewhere. Always pin by git rev and org.

Pinning and maturity: crates.io artifacts for monero-wallet and monero-clsag are stale placeholders (0.0.1), so production pins a specific monero-oxide git rev (its main, or the FCMP++ branch once that is the target), not crates.io. modular-frost is 0.11.0 on crates.io and on serai's next branch; serai develop still carries 0.10.1, so pin next or the 0.11.0 release, not develop. dkg is 0.6.1. The threshold path is audited: Cypher Stack reviewed monero-oxide (May 2025) and produced the FROSTLASS formalization and security proofs of the threshold-CLSAG scheme; modular-frost itself was audited by Cypher Stack (2023, IETF FROST draft 15). The FCMP++/CARROT target adds future dependencies (monero-oxide's FCMP++ branch, the CARROT key hierarchy and its Rust library, and the FROST-GSP multisig once audited), which are not yet production-ready.

Why not wrap wallet2 like eigenwallet does: eigenwallet wraps wallet2_api.h over cxx (monero-sys) and inherits Monero's native multisig. Plenum replaces native multisig with FROST, and the FROST-CLSAG path lives in monero-oxide's monero-wallet (send/multisig.rs), not in wallet2. So W1 uses monero-oxide. What Plenum borrows from eigenwallet is the surrounding patterns: the actor threading model (A3), native Tor (W4), node pooling (W7), and the typed headless API (W8).

A3. Parallelization strategy

The whole plan rests on one move: freeze the inter-crate interfaces first (W0), then every other workstream builds against those stable traits and mocks the rest. No workstream waits on another's implementation, only on the W0 contract, which is small (types plus trait signatures plus the message schema).

Three properties make the decomposition parallel:

  1. Trait contracts, not shared implementation. Each workstream depends only on trait definitions in plenum-core (W0). W2 codes against a SecureChannel trait, not against W3's OpenMLS code; W1 codes against a ChainSource trait, not against W7's pool. Each ships with an in-memory mock of its dependencies for its own tests.
  2. Three workstreams are fully standalone. W4 (transport), W6 (store), W7 (node access) have no dependency on the crypto and can be built and tested against real infrastructure (public relays, SQLite, public nodes) from day one, in parallel with W0 even.
  3. The actor model isolates state. The wallet core is owned by one task; the rest holds a cloneable WalletHandle that sends typed WalletCommands and receives WalletEvents over channels. Scanning, a DKG, a signing session, and a backup write can be in flight at once; serializing them through one owner removes a class of races. monero-oxide is pure Rust and thread-safe (unlike wallet2, which forced eigenwallet into thread-per-wallet for thread-local-storage reasons), but the single-owner actor is still the clean async model. The WalletCommand/WalletEvent enums live in W0, so W8 (the daemon) and the ceremony workstreams align without coupling.

Conventions: tokio multi-threaded runtime; thiserror for library error enums, anyhow only at binary and API boundaries; one Cargo workspace, one crate per workstream, privacy-critical crates kept small for individual audit.


Part B. Workstreams

Each workstream below lists: Scope, the Public interface it exposes (its half of the W0 contract), what it Depends on and what it Mocks, the concrete Tasks, Implementation notes and risks (the gotchas that lose money or privacy), and Done when (its definition of done and test bar).

W0. Contracts and workspace foundation

Crate: plenum-core. Blocks: everything. Depends on: nothing. Size: small, but must be done first and reviewed hard, because changing a frozen contract later forces rework across workstreams.

Scope. The shared vocabulary and the trait boundaries. No business logic. This is the one serial step.

Deliverables.

Done when: the workspace compiles with every trait defined and every mock implemented as a no-op or in-memory stub, and a trivial smoke test wires a MockTransport to a MockChannel. Frozen and tagged; later changes go through review as contract changes.

W1. Monero wallet engine

Crate: plenum-wallet. Depends on: W0, monero-oxide. Mocks: ChainSource (recorded stagenet fixtures). Implements: the Monero half of SpendAuthority plus scanning.

Scope. Everything Monero-protocol: derive keys and address from the group key, scan the chain for the wallet's outputs, build the deterministic unsigned transaction, compute fee and weight, serialize and recognize the final transaction. It does not run the FROST rounds (W2 drives those through SigningSession); it provides the monero-oxide transaction machine that W2 steps.

Tasks.

Implementation notes and risks.

Done when: against stagenet, the crate restores from both seed types, scans to a correct balance including subaddress receipts, survives a reorg, and builds a deterministic unsigned transaction that two independent instances produce byte-identically. Signing is exercised in W2 integration.

W2. FROST ceremonies (DKG and signing)

Crate: plenum-frost. Depends on: W0, modular-frost, dkg-pedpop, W1's UnsignedTx type. Mocks: SecureChannel (in-memory broadcast bus), and the monero-oxide transaction machine via W1's builder. Implements: the FROST half of SpendAuthority.

Scope. Drive the two ceremonies as message-exchange state machines over the channel: distributed key generation, and threshold signing. Detect and attribute misbehavior.

Tasks.

Implementation notes and risks.

Done when: an in-process test with N simulated members over the in-memory channel runs a full DKG to identical group keys and a full threshold signing to a valid stagenet transaction, including the blame and verify-share paths exercised by an injected faulty member.

W3. Group channel (MLS)

Crate: plenum-channel. Depends on: W0, OpenMLS. Mocks: Transport. Implements: SecureChannel.

Scope. One MLS group per multisig. End-to-end encryption, forward secrecy, sender authentication, membership and O(log n) re-keying, all transport-agnostic. OpenMLS directly, not an MLS-over-chat SDK (the whitepaper records why: such SDKs add stable-pubkey identity and persistently addressed events, the exact metadata pattern Plenum avoids).

Tasks.

Implementation notes and risks. Epoch desync is the failure mode: a missed Commit strands a member in a stale epoch, unable to decrypt. Requires ordered redelivery from the transport's store-and-forward and persisted epoch state. The channel mirrors the signer set exactly. Forward secrecy comes from periodic Update commits within a live group; a signer-set change is a brand-new group (no in-group remove), so a departed member shares no keys with it. External commits (restore) are accepted only from credentials hashing to a roster MemberId, so a leaked GroupInfo cannot admit a non-member to the channel.

Done when: an N-member group (over MockTransport) forms, exchanges application messages with verified senders, advances epochs on membership change, and a member that missed a Commit recovers to the current epoch from redelivered handshakes.

W4. Transport (SimpleX SMP queues, Tor or Nym)

Crate: plenum-transport. Depends on: W0, arti-client, Nym SDK. Mocks: nothing (standalone). Implements: Transport.

Scope. Deliver opaque frames over SimpleX SMP queues used as a store-and-forward bus, reached over Tor or Nym (per-member choice, no clearnet). Standalone and testable against real SMP servers from day one.

Tasks.

Implementation notes and risks. Tor hides IP but not timing; a global passive adversary can correlate a Tor member's flow and, via the signing rounds, a spend's timing. Nym's mixing breaks that. The network choice is per member, but because a spend is synchronized the weakest choice bounds the group's spend-timing privacy; jitter and cover (above) blunt the realistic adversary, and a high-assurance group can require Nym for all members as policy. SMP server liveness, rate limits, and censorship are mitigated by spreading a group's queues across several servers or self-hosting; because SMP holds a message until the recipient pulls and acks it, a member offline past nothing in particular simply receives its queued frames on reconnect (store_forward.envelope).

Done when: two instances exchange frames through SMP servers over Tor (and over Nym), with each member's queue address rotating correctly per epoch across spread servers, and a frame deposited while one is offline is delivered on its reconnect.

W5. Backup and restore

Crate: plenum-backup. Depends on: W0. Mocks: Store, SecureChannel, BackupSink. Implements: BackupSink for each destination, plus the blob lifecycle.

Scope. One client-side encrypted blob, replicated to three destinations, restorable with the seed alone.

Tasks.

Implementation notes and risks. The structural point the FROST move sharpens: the share is DKG-generated and the per-group MLS identity freshly generated, neither seed-derived, so the seed regenerates neither; the seed decrypts a surviving copy that carries both. A normal 25-word seed cannot restore a threshold wallet, which is why this layer exists. Restore of a lost device is non-interactive: no other member and no master takes part. The mechanism is an MLS external commit, which needs the group's current public GroupInfo; to keep restore genuinely self-contained, each client mirrors the latest public GroupInfo into its own backup blob and to the off-site stores on every epoch change, so the restorer reads it from its own backup surface. Stated honestly: restore is unilateral as long as a current GroupInfo sits in some backup copy; only if that copy is stale does a reachable member become necessary.

Done when: a blob round-trips through all three sink types (encrypt, replicate, fetch, decrypt, deserialize) and reconstitutes a working member position in a simulated group; the lost-device restore is non-interactive.

W6. Local store

Crate: plenum-store. Depends on: W0. Mocks: nothing (standalone). Implements: Store.

Scope. Local persistence with the eigenwallet boundary: secrets never go in the app database. State and UX metadata only.

Tasks.

Done when: CRUD plus reorg rollback pass, a property test confirms no plaintext secret is ever written (secret-bearing values are sealed before storage), and a headless instance restarts and resumes without re-entering the seed.

W7. Node access

Crate: plenum-node. Depends on: W0, monero-interface/monero-simple-request-rpc, W4's anonymity client. Mocks: can run against public nodes directly. Implements: ChainSource.

Scope. Read the chain without pinning one node. Copy eigenwallet's monero-rpc-pool: a local pool over several remote monerod, health-weighted random selection (success and failure counts, latency, a rolling bandwidth window, exponential rank weighting), automatic failover. No full node in the first release.

Tasks.

Implementation notes and risks. A wrong or hostile node can lie about the chain or fee or try to fingerprint, but cannot forge a transaction or steal funds (consensus checks signatures and balance, not the node). It can withhold blocks or feed a stale distribution; several nodes mitigate withholding, and deterministic decoy selection (W1) bounds the distribution-tampering surface.

Done when: the pool serves a full scan against public stagenet nodes, fails over when a node is killed mid-sync, and routes over Tor.

W8. Daemon orchestrator and API

Crates: plenum-daemon, plenum-api. Depends on: W0 and, at integration time, W1 through W7. Mocks: all of them early, via W0 mocks, so the API surface is built before the implementations land. Implements: the actor and the headless surfaces.

Scope. Wire the workstreams into one wallet core behind the WalletHandle actor, and expose it headlessly. Backend only; the GUI and Android clients are a separate plan and consume this daemon.

Tasks.

Implementation notes and risks. The real safety bound is cryptographic, not API-level: an agent on one member's client controls exactly one signature share and can never exceed the group's m-of-n threshold, no matter what it triggers, so "the agent may do everything" is bounded by the same threshold every member is bounded by. The UI/MCP co-equality means one shared command path with the GUI as a live observer in non-headless mode (the transparency mechanism); per-spend human gating is a configurable policy on top, not a separate MCP-only restriction. A headless instance is a full participant in the FROST and MLS groups (holds a share, runs ceremonies, stores backups), not a reduced client.

The shared command path has a development payoff: because the MCP API and the GUI drive the exact same WalletCommand set, an agent can operate the real GUI end to end, which doubles the MCP surface as an automated UI test harness (drive a flow over MCP, assert the resulting WalletEvents and GUI state) rather than maintaining a separate test driver.

Done when: the daemon runs an end-to-end DKG, a threshold spend, and a backup-and-restore through the REST and MCP surfaces against stagenet, with auth enforced and no secret reachable over the API.

W9. Auditable mode (optional)

Crate: plenum-attest (small), plus additions to plenum-frost (W2) and plenum-core (W0). Depends on: W0, W2 (the DKG and a Schnorr signing algorithm), W3 (to carry the threshold rounds). Mocks: as W2. Implements: the optional public-identity and attestation capability (whitepaper 8).

Scope. Let a group switch, by n-of-n consensus, from the default private mode into an auditable mode that exposes a persistent public identity, threshold-signed statements, and a threshold proof of reserves, without weakening private operation. Optional and sequenced after the core; statement signing is proof-system-independent and build-now-capable.

Tasks.

Implementation notes and risks. The hard invariant is compartmentalization: a property test must confirm the identity key, its signatures, and any attestation never derive from or expose the spend key, the MLS credential, the queue addresses, or a member IP. Switching back to private stops new attestations but cannot un-publish what is already out. Statement signing is cheap and available pre-FCMP++; the threshold reserve proof is the one piece with real cryptographic work and a deliberate privacy cost (it links the outputs it names to the public identity).

Done when: a group switches to auditable by unanimous (n-of-n) vote and defines its alias, publishes the identity key, and produces a verifiable m-of-n statement signature; the threshold zero-knowledge proof of reserves (minimum balance, no provenance) is specced with its own test plan as a research-grade item; and the compartmentalization property test passes.


Part C. Execution

C1. Dependency graph and phasing

Phase 0 (serial, short):   W0  ── freeze contracts + mocks
                             │
        ┌───────────┬────────┼────────┬───────────┬───────────┐
        │           │        │        │           │           │
Phase 1 (parallel): W4      W6       W7       W1          W2          W3
 (against W0 +     (standalone) (standalone) (W0+oxide)  (W0+frost)  (W0+OpenMLS)
  mocks)            real relays  SQLite   public nodes   mock chan    mock transport
                                                │  └────┬────┘           │
                                              W5 (W0, mocks)      ───────┘
                             │
Phase 2 (integration milestones, drop mocks): see C2
                             │
Phase 3:                    W8  ── actor + REST + MCP wire everything

C2. Integration milestones

Each milestone replaces a mock with a real implementation and is a synchronization point. Between milestones, work stays parallel.

C3. Failure-mode catalog and risk ranking

Ordered by where a bug loses money or privacy. Each maps to the workstream that owns it.

  1. FCMP++ schedule risk (W1, W2, strategy). The plan (A1) targets the post-fork FROST-GSP path and defers the signing core until FCMP++ is clean. The risk is timing: FCMP++ has no firm mainnet date (realistic late 2026 to 2027) and could slip further, while the clients ship first to build interest. Mitigations: the SpendAuthority boundary keeps the rest of the system moving; the audited threshold-CLSAG path is a working fallback if a mainnet wallet is needed before FCMP++ lands; track monero-oxide's FCMP++ branch and the GSP multisig audit.
  2. Threshold signing and key-image assembly (W2). Nonce reuse leaks shares; a bad partial image freezes or burns funds; a missing DLEq fallback makes a cheater unattributable. The fund-loss core.
  3. Deterministic decoy selection (W1). Privacy-critical, historically buggy in wallet2. Inherited from monero-oxide, not reimplemented; the deterministic-decoy fingerprint is the accepted price of multisig agreement. Eliminated by FCMP++.
  4. Bulletproofs+ proving and exact consensus serialization (W1). A byte mismatch is rejected by the daemon. Provided by monero-oxide; do not hand-roll.
  5. Scan correctness (W1). Wrong hash domains, view tags, varints, or cofactor handling silently lose funds. Primitives from monero-oxide; the risk is the custom loop, subaddress registration, reorg rollback.
  6. DKG blame and signer attribution (W2). Treating the happy path as the only outcome leaves the group stuck and unable to name a saboteur.
  7. MLS epoch desync (W3). A missed Commit strands a member; needs ordered redelivery and persisted epoch state.
  8. Seed derivation and the seed-is-not-the-share subtlety (W1, W5). Bit-exact Polyseed and legacy derivation; recovery must make unmistakable that the seed opens the backup and the share and per-group identity live inside it, that the seed alone with no surviving blob recovers nothing usable, and that a lost seed is unrecoverable.
  9. Transport liveness and metadata (W4). SMP server outages; mitigated by spreading queues across several servers, self-hosting, and SMP store-and-forward. Metadata addressed by epoch-rotated, server-spread queues with no stable identifier.

C4. Resolved decisions and remaining verification

The open questions from the prior draft are resolved below (research and decisions, 2026-06-10). What remains is verification against the pinned code, not open design.


Part D. The W0 contract in Rust

This is the concrete plenum-core crate: the types and traits every other workstream codes against (W0, A3). It is the one thing that must be frozen before the parallel work starts. The sketch below is normative for the interfaces and illustrative for the field-level detail; field names settle against the pinned monero-oxide and modular-frost revs (C4).

Conventions: async fn in traits is written with #[async_trait] (or native async traits once the toolchain target allows). All public types are Serialize + Deserialize (serde) unless they wrap a foreign key type, Clone where cheap, and Zeroize where they hold secrets. Newtypes wrap primitives so the compiler enforces that, for example, a SignerIndex is never confused with an Epoch.

Foreign types are re-exported, never redefined: ThresholdKeys, Participant from modular-frost; KeyPackage, MlsMessage from OpenMLS; MoneroAddress, FeeRate, Transaction, WalletOutput, KeyImage from monero-oxide. They appear in signatures only where unavoidable, and the monero-oxide ones appear behind SpendAuthority only, so the FCMP++/CARROT swap (A1) touches no other trait. The schema below is complete up front, cover-traffic and auditable-mode (ModeSwitch, Attest*) variants included, so W9 adds behaviour rather than message types and the W0 contract stays genuinely frozen (A3).

D1. Domain types and the message schema

// ---- identifiers (all newtypes, no bare primitives cross a boundary) ----
pub struct MemberId([u8; 32]);          // hash of the member's MLS credential pubkey
pub struct SignerIndex(pub modular_frost::Participant);  // NonZeroU16 under the hood
pub struct GroupId([u8; 32]);           // hash of the group public key
pub struct CeremonyId([u8; 16]);
pub struct ProposalId([u8; 16]);
pub struct Epoch(pub u64);              // MLS epoch
pub struct RendezvousTag([u8; 32]);     // a derived SMP queue address. operational: KDF(mls_exporter_secret(epoch), member_id, time_epoch); recovery: forward-only ratchet rs_k=H(rs_{k-1}), backward-secret
pub struct Txid([u8; 32]);
pub struct AttestId([u8; 16]);
pub struct CiphertextRef(pub Vec<u8>);  // manifest/locator for off-channel backup stores, not inline bytes
pub enum GroupMode { Private, Auditable }            // default Private
pub enum AttestKind { Statement, ReserveProof }

// ---- group membership ----
pub struct GroupConfig {
    pub threshold: u16,                 // m in m-of-n
    pub members: Vec<MemberId>,         // the n signers, ordered
}

/// The MemberId <-> SignerIndex bijection, fixed at setup, never reassigned.
pub struct Roster {
    pub group: GroupId,
    pub config: GroupConfig,
    by_member: BTreeMap<MemberId, SignerIndex>,
    by_index: BTreeMap<SignerIndex, MemberId>,
}

// ---- opaque wire frame: what Transport carries, what SecureChannel produces ----
pub struct OpaqueFrame(pub Vec<u8>);

// ---- a decrypted, sender-authenticated application message ----
pub struct Inbound {
    pub sender: MemberId,               // authenticated by MLS, not self-declared
    pub epoch: Epoch,
    pub msg: AppMessage,
}

// ---- the application message schema (whitepaper Appendix A) ----
pub enum SetupPhase { Commit, Share }   // the two FROST DKG rounds

// Complete and frozen up front: the auditable-mode variants below are part of the
// W0 schema from day one, so W9 adds behaviour, not message types (A3).
pub enum AppMessage {
    SetupRound      { ceremony: CeremonyId, phase: SetupPhase, payload: Vec<u8> },   // phase orders the 2 DKG rounds; no round_index
    SetupAddressConfirm { ceremony: CeremonyId, address: String },
    Chat            { body: String },
    SpendProposal   { proposal: ProposalId, unsigned_tx: Vec<u8>, amount: u64,
                      destination: String, note: Option<String> },   // amount/destination advisory: approvers rebuild + verify
    SpendCommit     { proposal: ProposalId, nonce_commitment: Vec<u8> },   // FROST round 1
    SpendSignature  { proposal: ProposalId, partial_sig: Vec<u8> },        // FROST round 2
    SpendFinalized  { proposal: ProposalId, txid: Txid },
    BackupBlob      { owner: MemberId, version: u32, ciphertext_ref: CiphertextRef, checksum: [u8; 32] },
    BackupRequest   { owner: MemberId, version: u32 },
    BackupDeliver   { owner: MemberId, version: u32, ciphertext_ref: CiphertextRef },
    StoreForward    { recipient: MemberId, inner: Box<AppMessage>, deposited_at: u64 },  // coarse epoch bucket, not wall-clock
    CoverTraffic    { nonce: [u8; 16] },                                   // timing cover (4.3)
    ModeSwitch      { proposal: ProposalId, target: GroupMode, alias: String, identity_pubkey: Vec<u8> },  // W9, n-of-n
    AttestProposal  { attest: AttestId, kind: AttestKind, payload: Vec<u8> },          // W9, m-of-n
    AttestSignature { attest: AttestId, partial_sig: Vec<u8> },                        // W9, m-of-n
}

D2. The cross-layer traits

// ===== Transport (W4 implements; W3 consumes) =====
#[async_trait]
pub trait Transport: Send + Sync {
    /// Push an opaque frame to a member's (derived, epoch-rotated) queue address over the chosen anonymity network.
    async fn post(&self, tag: RendezvousTag, frame: OpaqueFrame) -> Result<(), TransportError>;
    /// Subscribe to one's own queue address; yields frames the SMP server holds for it.
    fn subscribe(&self, tag: RendezvousTag) -> BoxStream<'static, OpaqueFrame>;
}

// ===== SecureChannel (W3 implements; W2/W5/W8 consume) =====
pub trait SecureChannel: Send {
    fn identity(&self) -> MemberId;
    fn epoch(&self) -> Epoch;
    /// Encrypt an application message into a wire frame.
    fn encrypt(&mut self, msg: &AppMessage) -> Result<OpaqueFrame, ChannelError>;
    /// Feed an inbound frame; transparently applies MLS handshake frames and
    /// returns any decrypted, sender-authenticated application messages.
    fn ingest(&mut self, frame: OpaqueFrame) -> Result<Vec<Inbound>, ChannelError>;
    /// Add a member during setup, driven by the inviting member (not master-only
    /// afterward; the group is leaderless). Returns the handshake frame to broadcast.
    /// There is no remove_member: a signer-set change is a brand-new group (3.2, W2/W5),
    /// never an in-group remove.
    fn add_member(&mut self, kp: KeyPackage) -> Result<OpaqueFrame, ChannelError>;
    /// Restore-rejoin via an MLS external commit using the current public GroupInfo;
    /// no other member acts (W5). Re-keys in one step. The channel MUST accept an
    /// external commit only from a credential hashing to a roster MemberId (W3),
    /// so a leaked GroupInfo cannot let a non-member into the channel.
    fn external_rejoin(&mut self, group_info: GroupInfo) -> Result<OpaqueFrame, ChannelError>;
    /// Credential fingerprint for the out-of-band identity check (5.2).
    fn fingerprint(&self) -> CredentialFingerprint;
}

// ===== SpendAuthority: the FCMP++ swap boundary (W1 + W2 implement) =====
// The ONLY trait that mentions monero-oxide types. Swapping CLSAG -> FCMP++/GSP
// replaces the impl behind this trait and nothing else (A1).
pub trait SpendAuthority: Send + Sync {
    /// Deterministic Monero address from the group public key (all members agree).
    fn derive_address(&self, group_key: &GroupPublicKey) -> MoneroAddress;

    /// Build the byte-identical unsigned tx every signer must reproduce.
    /// `proposal_seed = KDF(group_secret, unsigned_tx_core, recent_block_hash)`, so the
    /// decoys, masks, and ordering are deterministic across signers yet the proposer
    /// cannot grind them; approvers re-derive and verify it (anti-decoy-grinding, W1, 7.1).
    fn build_unsigned(
        &self,
        inputs: &[OwnedOutput],
        payments: &[(MoneroAddress, u64)],
        change: &MoneroAddress,
        fee_rate: FeeRate,
        proposal_seed: [u8; 32],
    ) -> Result<UnsignedTx, SpendError>;

    /// Open a two-round threshold signing session over an unsigned tx, using
    /// this member's FROST share. Only `x` is threshold-shared; the commitment
    /// mask is handled inside the impl (C4).
    fn signing_session(
        &self,
        unsigned: UnsignedTx,
        keys: ThresholdKeys,
    ) -> Result<Box<dyn SigningSession>, SpendError>;
}

/// The signing state machine W2 drives by passing messages over the channel.
/// Maps to whitepaper spend.commit (round 1) and spend.signature (round 2).
pub trait SigningSession: Send {
    /// Round 1: this signer's nonce commitments (+ key-image share addendum).
    fn commit(&mut self) -> Result<SignCommit, SpendError>;
    /// Round 2: given all signers' round-1 commits, this signer's partial sig.
    fn sign(&mut self, commits: &BTreeMap<SignerIndex, SignCommit>)
        -> Result<SignShare, SpendError>;
    /// Aggregate the partials into the final transaction. On failure, names the
    /// faulty signer via the per-share verify (W2 attribution).
    fn complete(self: Box<Self>, shares: &BTreeMap<SignerIndex, SignShare>)
        -> Result<SignedTx, SignFault>;
}

pub enum SignFault {
    NotEnoughShares { have: u16, need: u16 },
    FaultyShare(SignerIndex),       // identified cheater
    Build(SpendError),              // skeletons diverged: a determinism bug
}

// ===== ChainSource (W7 implements; W1 consumes) =====
#[async_trait]
pub trait ChainSource: Send + Sync {
    async fn tip(&self) -> Result<(u64, [u8; 32]), ChainError>;          // height, hash
    async fn blocks(&self, from: u64, to: u64) -> Result<Vec<ScannableBlock>, ChainError>;
    async fn outs(&self, indices: &[u64]) -> Result<Vec<RingMember>, ChainError>;
    async fn output_distribution(&self) -> Result<OutputDistribution, ChainError>;
    async fn fee_rate(&self) -> Result<FeeRate, ChainError>;
    async fn broadcast(&self, tx: &SignedTx) -> Result<Txid, ChainError>;
    async fn key_image_spent(&self, ki: &KeyImage) -> Result<bool, ChainError>;
}

// ===== Store (W6 implements; everyone persists through it) =====
// Invariant: NO PLAINTEXT secret at rest. The FROST share, the per-group MLS
// credential, and seed-derived keys
// live only in the encrypted backup blob (W5) or an OS keystore; secret-bearing
// args (e.g. ChannelEpochState) are sealed by the impl under the seed-derived key
// before write. A property test enforces no-plaintext-secret (W6 "done when").
#[async_trait]
pub trait Store: Send + Sync {
    async fn save_outputs(&self, outs: &[OwnedOutput]) -> Result<(), StoreError>;
    async fn load_outputs(&self) -> Result<Vec<OwnedOutput>, StoreError>;
    async fn scan_cursor(&self) -> Result<u64, StoreError>;
    async fn set_scan_cursor(&self, height: u64) -> Result<(), StoreError>;
    async fn rollback_to(&self, height: u64) -> Result<(), StoreError>;  // reorg
    async fn save_roster(&self, roster: &Roster) -> Result<(), StoreError>;
    async fn load_roster(&self) -> Result<Option<Roster>, StoreError>;
    async fn save_epoch(&self, state: &ChannelEpochState) -> Result<(), StoreError>;
    async fn load_epoch(&self) -> Result<Option<ChannelEpochState>, StoreError>;
    async fn save_labels(&self, labels: &[AddressLabel]) -> Result<(), StoreError>;
    async fn load_labels(&self) -> Result<Vec<AddressLabel>, StoreError>;
}

// ===== BackupSink (W5 implements once per destination: local, S3, channel) =====
#[async_trait]
pub trait BackupSink: Send + Sync {
    async fn put(&self, owner: MemberId, version: u32, ciphertext: &[u8]) -> Result<(), BackupError>;
    async fn get(&self, owner: MemberId, version: u32) -> Result<Vec<u8>, BackupError>;
    async fn list(&self, owner: MemberId) -> Result<Vec<u32>, BackupError>;
}

// ===== KeyMaterial: the one place secret material is held (seed + per-group MLS credential) =====
// The FROST share is NOT here: it is DKG-generated and lives in the backup blob.
// The MLS credential is generated per group and loaded from the blob, not seed-derived.
pub trait KeyMaterial: Send + Sync {
    fn member_id(&self) -> MemberId;
    // Operations, not keys: secrets never cross the boundary by value.
    fn sign_mls(&self, msg: &[u8]) -> MlsSignature;        // per-group credential, loaded from backup
    fn seal_backup(&self, plaintext: &[u8]) -> Vec<u8>;   // Argon2id-derived key, memory-hard
    fn open_backup(&self, ciphertext: &[u8]) -> Result<Vec<u8>, KeyError>;
}

D3. The actor protocol and errors

// ===== Actor protocol: what the daemon (W8) sends/receives over WalletHandle =====
pub enum WalletCommand {
    CreateIdentity,
    CreateGroup  { config: GroupConfig },
    Join         { invite: Invite },
    SendChat     { body: String },
    ProposeSpend { payments: Vec<(MoneroAddress, u64)>, note: Option<String> },
    Approve      { proposal: ProposalId },
    Reject       { proposal: ProposalId },
    ScanTick,
    BackupNow,
    Restore      { source: RestoreSource },
    SwitchMode   { target: GroupMode, alias: Option<String> },  // W9 (going auditable: n-of-n)
    SignStatement{ text: String },                              // W9 (m-of-n)
    ProveReserves{ min_balance: u64 },                          // W9 (m-of-n, ZK)
    Status,
}

pub enum WalletEvent {
    IdentityReady  { member: MemberId },
    GroupReady     { group: GroupId, address: MoneroAddress },
    Scanned        { height: u64, balance: u64 },
    Received       { address: MoneroAddress, amount: u64, txid: Txid, confirmations: u64 },
    ChatReceived   { from: MemberId, body: String },
    SpendProposed  { proposal: ProposalId, by: MemberId, amount: u64, destination: String },
    SpendApproved  { proposal: ProposalId, by: MemberId, have: u16, need: u16 },
    SpendBroadcast { proposal: ProposalId, txid: Txid },
    SpendFinalized { proposal: ProposalId, txid: Txid },
    BackupReplicated { version: u32, sinks: u8 },
    CeremonyFault  { ceremony: CeremonyId, culprit: Option<MemberId> },
    ModeSwitched   { mode: GroupMode },                          // W9
    AttestationReady { attest: AttestId, kind: AttestKind },     // W9 (statement / reserve proof)
    Error          { context: String },
}

// The handle every frontend (GUI, REST, MCP) holds. One owning task drives it.
#[derive(Clone)]
pub struct WalletHandle { /* mpsc::Sender<(WalletCommand, oneshot reply)> */ }
impl WalletHandle {
    pub async fn send(&self, cmd: WalletCommand) -> Result<(), WalletError>;
    pub fn events(&self) -> BoxStream<'static, WalletEvent>;   // GUI mirrors MCP here (decided, C4)
}
// ===== Error taxonomy: per-module thiserror enums, never panic across a boundary =====
#[derive(thiserror::Error, Debug)] pub enum TransportError { /* relay, network, timeout */ }
#[derive(thiserror::Error, Debug)] pub enum ChannelError   { /* mls, epoch desync, decrypt */ }
#[derive(thiserror::Error, Debug)] pub enum SpendError     { /* build, frost, fee, serialize */ }
#[derive(thiserror::Error, Debug)] pub enum ChainError     { /* rpc, reorg, rejected */ }
#[derive(thiserror::Error, Debug)] pub enum StoreError     { /* io, migration, rollback */ }
#[derive(thiserror::Error, Debug)] pub enum BackupError    { /* sink io, missing, checksum */ }
#[derive(thiserror::Error, Debug)] pub enum WalletError    { /* wraps all of the above */ }

What W0 freezes and what it defers. Frozen: the trait method signatures, the AppMessage variants, the WalletCommand/WalletEvent enums, the newtype identifiers. Deferred to each workstream: the bodies, the concrete foreign-type fields inside OwnedOutput/UnsignedTx/ScannableBlock (these settle against the pinned monero-oxide rev), and the on-wire serialization details of OpaqueFrame. A change to anything frozen is a contract change and goes through review, because it ripples across workstreams (A3).