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:
- Trait contracts, not shared implementation. Each workstream depends only on trait definitions in
plenum-core(W0). W2 codes against aSecureChanneltrait, not against W3's OpenMLS code; W1 codes against aChainSourcetrait, not against W7's pool. Each ships with an in-memory mock of its dependencies for its own tests. - 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.
- The actor model isolates state. The wallet core is owned by one task; the rest holds a cloneable
WalletHandlethat sends typedWalletCommands and receivesWalletEvents 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. TheWalletCommand/WalletEventenums 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.
- Domain types:
MemberId(the MLS credential identity),SignerIndex(wrapsmodular-frostParticipant, aNonZeroU16),GroupId,CeremonyId,ProposalId,Epoch,GroupConfig { threshold: u16, members: Vec<MemberId> },Roster(theMemberIdtoSignerIndexbinding),RendezvousTag. - Message schema (whitepaper Appendix A) as an enum
AppMessage:SetupRound { ceremony_id, phase, payload },SetupAddressConfirm { ceremony_id, address },ChatMessage { body },SpendProposal { proposal_id, unsigned_tx, amount, destination, note },SpendCommit { proposal_id, nonce_commitment },SpendSignature { proposal_id, partial_sig },SpendFinalized { proposal_id, txid },BackupBlob { owner_id, version, ciphertext_ref, checksum },BackupRequest/BackupDeliver { owner_id, version },StoreForwardEnvelope { recipient_id, inner, deposited_at },CoverTraffic { nonce },ModeSwitch { proposal_id, target, alias, identity_pubkey },AttestProposal { attest_id, kind, payload },AttestSignature { attest_id, partial_sig }. The auditable-mode variants are part of the frozen schema from day one (W9 implements behaviour, not new wire types). Versioned envelope; sender is the authenticated channel identity, never a field. - The cross-layer traits (the contracts):
Transport:async fn post(&self, tag: RendezvousTag, frame: OpaqueFrame);fn subscribe(&self, tag: RendezvousTag) -> Stream<OpaqueFrame>. Outbound-only, opaque frames. (W4 implements.)SecureChannel:fn encrypt(&mut self, msg: AppMessage) -> OpaqueFrame;fn ingest(&mut self, frame: OpaqueFrame) -> Vec<Inbound>(returns decrypted, sender-authenticated messages, transparently handling MLS handshake frames); membership ops (add,remove,commit);fn epoch(&self) -> Epoch;fn identity(&self) -> MemberId. (W3 implements.)SpendAuthority(the FCMP++ swap boundary):fn derive_address(group_key) -> MoneroAddress;fn build_unsigned(inputs, payments, change, fee_rate, proposal_seed) -> UnsignedTx;fn signing_session(unsigned: UnsignedTx, keys: ThresholdKeys) -> Box<dyn SigningSession>whereSigningSessiondrives the FROST rounds (preprocess() -> Round1,sign(round1s) -> Round2,complete(round2s) -> SignedTx); plus key-image assembly hooks. (W1 and W2 jointly implement; CLSAG is the interim/reference implementation, FCMP++/GSP the target, A1.)ChainSource:async fn blocks(range) -> Vec<ScannableBlock>;async fn get_outs(indices) -> ...;async fn output_distribution() -> ...;async fn fee_rate() -> FeeRate;async fn broadcast(tx) -> Result<Txid>;async fn key_image_spent(ki) -> bool. (W7 implements.)Store: persistence of outputs, key images, scan cursor, roster, epoch state, labels, ceremony and proposal state; with reorg rollback. (W6 implements.)BackupSink:async fn put(&self, owner, version, ciphertext);async fn get(&self, owner, version) -> ciphertext;async fn list(&self, owner). (W5 implements per destination.)KeyMaterial: the one type that holds secret material (the seed, and the per-group MLS credential loaded from the blob). It exposes operations, not keys (sign_mls,seal_backup/open_backup), so secrets never cross the trait boundary by value; the MLS credential is generated per group and loaded from the backup, not seed-derived, while the backup key is memory-hard (Argon2id), centralized,Zeroized, and never duplicated.
- Actor protocol:
WalletCommandandWalletEventenums (create group, join, propose spend, approve, scan tick, backup now, restore, status). - Error taxonomy and a shared
plenum-testsupport crate with the in-memory mocks (MockTransport,MockChannel,MockChainSource,MockStore) so every other workstream can test in isolation from day one.
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.
- Seed and keys. Polyseed (16 words, encodes the wallet birthday and so the restore height, passphrase support) as default for new wallets, plus legacy 25-word (24 words equal the 256-bit private spend key
b, plus a checksum word; private view keya = sc_reduce32(Keccak(b))) for restore. Bit-exact derivation. Expose the birthday as a restore height. - Address derivation. From the Ed25519 group public key, derive the Monero address via
GuaranteedViewPairplus aSubaddressIndex(guaranteed featured addresses for burning-bug immunity). Fixed small subaddress slot set (external receive, change, and so on), following serai's processor convention.Hs("SubAddr\0" || a || major || minor),(0,0)special-cased. - Scanning.
GuaranteedScanner::scan(ScannableBlock) -> Timelocked<Vec<WalletOutput>>per block. Build the sync loop, the cursor, reorg detection and rollback, restore-height handling. Register subaddresses ahead of the cursor. - Deterministic build.
SignableTransaction::new(rct_type, outgoing_view_key, inputs, payments, change, data, fee_rate); inputs asOutputWithDecoys::fingerprintable_deterministic_new;Change::guaranteed. Theoutgoing_view_keyand the decoy seed derive deterministically from the proposal so all signers compute the identical skeleton. The proposal seed is bound to a fresh, proposer-uncontrollable anchor (a recent block hash) plus the transaction core (inputs, outputs, amounts), not chosen freely by the proposer, and approvers re-derive and verify it, so a malicious proposer cannot grind a privacy-weakening decoy set. - Fee and weight. Fetch
FeeRatefromChainSource; computenecessary_fee()from transaction weight (the Bulletproofs+ clawback penalty applies above two outputs). Decide the fee before signing; it is part of the agreed skeleton. - Eventuality tracking.
send::Eventualityto recognize whether a specific signed transaction landed, so a session survives restart without double-spending. - Serialize and broadcast. Emit the exact consensus byte layout; broadcast via
ChainSource; surface granular reject reasons.
Implementation notes and risks.
- Scanning correctness is silent-fund-loss territory: subaddress pre-registration before the carrying block, the additional-tx-pubkey path (tx_extra tag 0x04) for subaddress and multi-destination receipts, the 1-byte view-tag fast-reject (skips ~99.6% of EC work; do not bypass), unlock windows (10 blocks, coinbase 60). monero-oxide implements the primitives; the risk is in the custom loop, reorg rollback, and persistence around it.
- Determinism is the spine of multisig: any difference in decoys, fee, ordering, or masks makes W2's shares fail to combine. Use
fingerprintable_deterministic_new(accept the reproducible-ring fingerprint as the forced price of agreement), sort inputs by key image, never inject randomness into the build. - The decoy picker (gamma distribution over output age, fed by the RCT output distribution, with the historically buggy gamma-from-tip and exactly-10-blocks-old boundaries) is inherited from monero-oxide; do not reimplement.
- Address derivation and the build live behind
SpendAuthority; CARROT rewrites them at the fork, and per A1 the CARROT/FCMP++ variant is the eventual target while the CLSAG variant here is the interim/reference. Keep no CLSAG or address assumption leaking out of this crate. This crate's signing-dependent parts are the ones that wait for FCMP++ (C1).
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.
- DKG (PedPoP). Two communication rounds plus optional blame, any threshold: round 1
KeyGenMachine::generate_coefficients(polynomial commitments with a Schnorr proof of knowledge of the constant term, the rogue-key defense), round 2SecretShareMachine::generate_secret_shares(one per-recipient encrypted share), localKeyMachine::calculate_share(decrypt and multiexp-verify), thenBlameMachine::complete() -> ThresholdCoreorBlameMachine::blame(...)which names the faultyParticipantdeterministically. Output:ThresholdKeys<Ed25519>(the member's share, the identical group key, the verification shares). - Round sequencing over the channel. Each round is a barrier driven by the master (sequencer and relay only): collect all N round-1 commitments, relay, open round 2 (per-recipient shares travel addressed inside MLS). Bind
SignerIndextoMemberIdonce via the roster; never reassign within a ceremony. The master never sees a secret share. - All-N address gate. After the DKG, each member derives the address (via W1) and reports it; the ceremony is valid only when all N report identical. A substituted commitment or share changes the group key and so the address, so a malicious or buggy master fails closed.
- Signing session. Implement
SigningSessionover monero-oxide'sSignableTransaction::multisig(keys) -> TransactionMachine(oneAlgorithmMachine<Ed25519, ClsagMultisig>per input): round 1preprocess(nonce commitments plus theClsagAddendumkey-image share), round 2sign(reconstruct each shared key image by Lagrange-summing addendum shares, derive binding factors from all commitments, emit the partial),complete(aggregate into each CLSAG, assemble theTransaction). Map to the whitepaper messages:spend.commitis round 1,spend.signatureis round 2. - Attribution. On a failed signing combine, call
Algorithm::verify_shareper participant (the returned scalar-point pairs must batch-sum to identity; the non-zero contributor is the culprit). On a failed DKG, useBlameMachine::blame.
Implementation notes and risks.
- The reason for
modular-frostover a single-generator FROST library: CLSAG commits each nonce against two generators,Gand the key-image generatorH_p(P).Algorithm::nonces() -> Vec<Vec<C::G>>is the hook;ClsagMultisig::nonces()returns the two-generator vector. No single-generator library can sign CLSAG without extending the nonce model. - Nonce reuse is catastrophic: reusing a round-1 preprocess across two messages leaks the secret share.
cache()/from_cacheexist for precompute and are a footgun. Rule: fresh preprocess per attempt; on abort, discard and re-preprocess; never replay. - Key-image assembly is the crux.
I = x·H_p(P)with no one holdingx; becausexis linear,I = Σ x_i·H_p(P).ClsagMultisigdoes this inside theAlgorithm(preprocess_addendum=key_image_generator · secret_share,process_addendumLagrange-accumulates,verify_shareruns the dual-generator DLEq batch check that catches a member submittingI_i + Δto freeze or burn funds). The batch check must fall back to per-signer verification to identify the cheater. Key-image sync is per output: a new output needs another addendum exchange before it can be spent. - The commitment-mask component
zdoes not need to thread through the DKG or FROST: only the spend-key sharexis threshold-shared.z(the real-minus-pseudo-out mask delta) is a known per-transaction value the coordinator supplies viaClsagMultisigMaskSender, and themu_C·zterm is folded in once at final aggregation. Thread onlyxthrough the ceremony (resolved, C4). - Restart safety: use W1's
Eventualityto check whether a spend already broadcast before re-attempting. - Proposal verification (anti display-spoofing): before contributing a signature, an approver rebuilds the unsigned transaction from the proposal's parameters via
SpendAuthority::build_unsignedand confirms it matches the receivedunsigned_tx; the client then displays the destination and amount parsed from that verified transaction, never the self-declaredamount/destinationfields onspend.proposal. A mismatch aborts (fail closed). - Spend coordination, no privileged coordinator: the proposer coordinates its own proposal (collects nonce commitments and partial signatures for that
proposal_id), a per-proposal role with no standing power. The signing subset is the firstmmembers to approve. A signer dropping mid-round aborts that attempt; restart discards the cached preprocess and re-preprocesses fresh nonces (never replay, per the nonce-reuse rule above). Concurrent proposals are serialized by locking the inputs over the coordination layer before any broadcast, so in the normal case two competing proposals never both reach the mempool (no mempool observer sees two txs spend one group key image); first-broadcast-wins plusEventualityis only the last-resort tiebreaker for a genuine race.
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.
- Per-group identity. Generate a fresh long-term MLS credential keypair for each group (not seed-derived), so a member is unlinkable across the groups they join. Like the FROST share it is created at setup and stored in the backup blob (W5), not regenerable from the seed;
KeyMaterialloads it from the backup to sign. Its public-key hash is theMemberIdwithin that group; it maps to theSignerIndex. No directory, no identity shared across groups. - Onboarding. Generate credential and KeyPackage locally; the master applies Add and Welcome. Expose fingerprint material for the out-of-band comparison.
- Application messages.
encrypt(AppMessage)andingest(frame); transparently process MLS handshake frames insideingest; return sender-authenticated decrypted messages. - Epoch management. Persist epoch state sealed via
Store(W6); on restart, resume at the right epoch. Send periodic Update commits for forward secrecy independent of membership. A restore rejoins via an MLS external commit (W5), not an Add by another member. A signer-set change is a new group (W2/W5), not an in-group remove. - Ordered handshake handling. Apply Commits in order; recover a member that missed a Commit using the redelivered handshake messages from store-and-forward.
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.
- Outbound-only SMP client. Connect out to several SMP servers at once; no hosted service (a group may self-host its own SMP server). One inbound queue per member; a sender does the fan-out client-side, pushing each frame to the other N-1 members' queues. The N queues are spread across different servers, each reached over its own circuit, so no single server sees the whole group or correlates queues by source IP.
- Anonymity networks. Embedded Tor via
arti-client(no system tor daemon), and the Nym mixnet SDK for cross-user mixing. The same client is reused for node access (W7). Per-member selection. - Epoch-rotated queue addresses. Each member's inbound queue address is derived, not assigned:
queue_addr(member, epoch) = KDF(mls_exporter_secret(epoch), member_id, time_epoch). On every MLS commit all members derive fresh queues and tear down the old ones, so a server cannot link epoch-k queues to epoch-k+1. Next-epoch addresses are distributed in-band one epoch ahead; the epoch-0 addresses come from the invite. There is no stable wire identifier, and no flood or trial-decrypt: delivery is addressed but the address is short-lived and group-derived. - Recovery queue. The regroup-after-total-outage backstop is a separate recovery queue built as a forward-only, clock-indexed hash ratchet (not a stable secret-derived beacon):
rs_0 = KDF(group_recovery_secret, "plenum-recovery-v1")seeded at formation then the root zeroized,rs_k = H(rs_{k-1})stepped per coarse bucketk = c - c_form,recovery_queue = KDF(rs_k, "recovery-queue"). Only the currentrs_kis persisted (in the backup blob), overwritten forward; a returning member forward-hashes from its last stored state and scans buckets{k-1, k, k+1}for clock drift. This gives backward secrecy: a recovered state discloses queue addresses for buckets>= konly, never the past, so the retroactive archive scan is eliminated. A currently-compromised endpoint can still follow it forward, bounded by the group's lifetime (membership change rotates to a fresh root, 6.1).group_recovery_secretMUST be dedicated to this purpose; never reuse a secret persisted or used elsewhere as the ratchet root. The recovery queue carries cover traffic plus the PoW stamp like the operational path, and a group may disable it by policy and regroup out-of-band (5.1). During setup, before the group secret exists, the ceremony meets atKDF(ceremony_secret, member_index, time_epoch)queues from a one-timeceremony_secretcarried in the invite; the master is outbound-only at those queues like everyone else (no hosted delivery point). - Abuse bounds. A per-message proof-of-work stamp (applied on the ceremony queues too, so a leaked invite cannot flood setup for free), reliance on SMP server rate limits, per-member store-and-forward quotas, and the fact that epoch rotation expires any leaked queue address.
- Timing defence. Jitter the synchronized signing rounds over a randomized window and emit
cover.trafficdummies, blurring the burst-and-round pattern against a nosy server, a single observer, or a chain-timing correlator. This is not mixing (only Nym defeats a global passive adversary); it is the timing hardening both tiers run.
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.
- Blob assembly. Serialize the member's position: the FROST key share (
ThresholdKeys::serialize()), group public key and roster, shared view key, scan state and outputs and key images, MLS group state and epoch, personal data (labels). Version it. - Encryption. Encrypt client-side under the backup key, which
KeyMaterialderives from the seed through a memory-hard KDF (Argon2id, high parameters) so a captured blob resists offline brute force, even though every fellow member holds a copy of the ciphertext. Pad to size buckets to reduce size leakage. Warn or block when two members of one group configure the same off-site store, so no single store ever holds a threshold of blobs. - Replication to three sinks. Local filesystem; one or more S3-compatible endpoints (ciphertext-only, untrusted buckets fine, reached over the anonymity network); the other members over the channel (
backup.blob, with store-and-forward). All hold the identical ciphertext. - Restore flows. Lost device, seed survives: fetch from any surviving copy, decrypt, deserialize the share and state, rejoin the MLS group via an MLS external commit using the current public GroupInfo (no other member or master acts). Lost seed too: that member's share is gone, funds safe while the threshold of others survives; rebuild via a fresh DKG (W2) and sweep. Catastrophic loss beyond threshold: unrecoverable by design (sweeping itself needs the old threshold); the three-destination redundancy is the mitigation.
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.
- Schema and migrations (SQLite via
sqlx): discovered outputs, computed key images, scan cursor, roster, channel epoch state, address labels, ceremony and proposal state. - Reorg rollback support: roll back outputs and key images affected by a reorg to a given height.
- No plaintext secret at rest. Secret material never hits disk in the clear. The FROST share, the per-group MLS credential, and seed-derived keys live in memory while running and persist only inside the encrypted backup blob (W5). Secret-bearing channel state (the MLS epoch / ratchet) is sealed under the seed-derived key before it is stored. A headless instance unlocks this local sealed store once at boot, from an operator-supplied passphrase or an OS keystore/TPM, so it survives restarts without re-entering the seed and never carries a secret over the network. The optional local app-lock password gates the running GUI, may be empty, and is not part of backup or store encryption.
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.
- Pool and selection. Maintain a node list with per-node stats; select by weighted random; fail over on error.
- RPC surface for
ChainSource: bulk block download for scanning,get_outs(ring members for decoys),get_output_distribution(required by the decoy picker), fee estimate, raw-transaction broadcast, key-image-spent checks. - Over the anonymity network. Every node connection goes over the member's Tor or Nym client (W4).
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.
- The actor. One owning task holds the authoritative wallet state and drives
WalletCommands (create group, join, propose spend, approve, scan tick, backup, restore, status), emittingWalletEvents. It sequences scanning, ceremonies (W2), channel I/O (W3 over W4), node reads (W7), and backups (W5). - REST surface. Define the API as a Rust trait and generate client and server from it (eigenwallet uses
jsonrpseewith#[rpc(client, server)]). The human and script path. - MCP surface, co-equal with the UI (decision 2026-06-10). The UI and the MCP API are peers over the same
WalletCommandset: anything the UI can trigger, an MCP agent can trigger, and vice versa. There is no reduced MCP command set. When not in headless mode (a GUI is attached), the GUI subscribes to the same command andWalletEventstream, so the user sees in real time what the agent does: every action the agent triggers is mirrored live in the UI, and the user can intervene. In headless mode there is no watching UI, and the configured policy plus the group threshold are the only bounds. - Auth and safety. Authenticate the caller (keypair-based, per eigenwallet) and bind to a member. Never expose the share or the seed over the API. Never let the API grant spend authority beyond the threshold.
- Local-only seed, headless provisioning. The seed is created and entered only on the device (GUI or a local provisioning step), never over the REST/MCP network API. A headless instance is provisioned out of band with its seed into a local sealed keystore (W6) and unlocked once at boot (operator passphrase or OS keystore/TPM); the API never transmits or returns the seed or the FROST share in either direction.
- Threshold bounds unilateral action, not blast radius. The API never grants spend authority beyond the threshold, and a fully hijacked agent stays bounded to one share (it cannot exceed the threshold, reach a group it is not in, or touch the seed or share). But one share is the structural swing vote in a 2-of-3 arbiter escrow, so a compromised arbiter signer can settle disputes wrongly with one cooperating party. Prompt injection of an agent that judges counterparty-supplied evidence is in scope and contained, not prevented. Policy (value caps, budgets, allowlists, per-spend gates) is defence-in-depth, not a containment boundary: it runs in the agent's own trust domain, so whoever owns the daemon owns the policy. The real containment boundary is authority outside that domain: a watching human via the live GUI mirror, or, for headless arbiters, a swing vote split across k independent operators or a second-domain co-sign above a value threshold. Caps are co-located with the agent, so size them to the accepted worst-case loss (the security parameter for a single compromise), and separate adversarial-evidence parsing from the signing component where the deployment allows. See usecases 3.7.3.
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.
- Group mode and switch. A
GroupMode(Private or Auditable) in the roster. Going auditable requires n-of-n consensus (every member must agree; any one can veto), and the switch is where the group defines its public alias and identity key. Switching back to private, and the individual attestations afterward, run at the normal m-of-n. Persisted in the store and the backup blob. TheGroupMode,mode.switch(carryingaliasandidentity_pubkey) andattest.*AppMessagevariants, and theSwitchMode/SignStatement/ProveReservesWalletCommands, are part of the frozen W0 schema from day one (D1, D3); W9 only implements their behaviour and does not change the contract. - Separate identity key (the alias). Derive a dedicated public identity key for the chosen alias, distinct from the wallet spend key, the MLS credentials, and the transport and rendezvous keys, generated by a short dedicated DKG run at switch time (decided, C4). Compartmentalization is the invariant: the alias key links to nothing internal.
- Threshold statement signing. Reuse
modular-frostwith the plainSchnorr/IetfSchnorralgorithm (notClsagMultisig) over the identity key: two FROST rounds produce one standard Ed25519/Schnorr signature, verifiable by anyone against the published key. No Monero specifics and no FCMP++ dependency, so this part is build-now-capable. - Succession certificates. On a membership change (a new group, W2 / 6.1), the old group signs an endorsement of the new identity key at the full n-of-n (as consequential as going public, so no subset can transfer the brand to an attacker key) and publishes it to a public append-only log, so the published identity survives the new-group model without resharing and a covert hijack is visible. Verifiers follow the chain.
- Proof of reserves (minimum balance only, no provenance). The attestation must reveal only a minimum balance, never which outputs back it or where they came from. That rules out both the native Monero reserve proof (reveals the named outputs) and view-key publication (reveals all incoming); it requires a zero-knowledge proof of reserves (the MProve / MProve+ lineage) that hides the owned outputs in the chain's anonymity set, made threshold so no single party holds the spend key. This is the heaviest cryptographic component in the project: not off-the-shelf in monero-oxide, threshold-izing a ZK proof of reserves is frontier work, and it is a strong candidate to build on the post-FCMP++ full-chain membership machinery rather than CLSAG-era constructions. Treat it as a research-grade, likely post-fork item; statement signing ships long before it. (View-key publication stays available only as a separate, explicitly fully-transparent option for groups that want total openness; it reveals provenance and is not the reserve attestation.)
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
- What starts on day one (before W0 is even frozen, against draft traits): W4, W6, W7. They have no crypto dependency and test against real infrastructure.
- What starts the moment W0 is frozen: W2's DKG, W3, W5 in parallel, each against W0 mocks. These are proof-system-independent.
- What waits for FCMP++ (A1 decision): W1 (the Monero engine: CARROT changes address derivation and scanning) and W2's signing session (the GSP threshold Algorithm replaces
ClsagMultisig). The CLSAG implementations of both stand as the interim/reference and the fallback, but the release target is the post-fork path, so heavy signing-core work is sequenced after FCMP++ is clean. - Sequencing per A1: the clients (separate UI plan) and the proof-system-independent backend (W2-DKG, W3, W4, W5, W6, W7) go first, to ship something and build public interest. The signing core (W1, W2-signing) follows FCMP++.
- Critical path to a transacting wallet: W0 → the independent backend and clients → FCMP++ mainnet plus audits → W1/W2-signing against GSP → M4 → W8.
C2. Integration milestones¶↑
Each milestone replaces a mock with a real implementation and is a synchronization point. Between milestones, work stays parallel.
- M1. Real channel over real transport. W3 runs over W4 instead of
MockTransport. Proves MLS frames survive epoch-rotated SMP queues spread across servers. - M2. DKG end-to-end. W2 over W3 over W4. N members reach an identical group key and pass the all-N address gate over the real network. First on-network ceremony.
- M3. Scan and balance. W1 over W7 over W6. Restore, scan to correct balance, survive a reorg, persist. Independent of M1 and M2; can land in parallel.
- M4. Threshold spend end-to-end. W2 plus W1 over the channel: propose, FROST round 1 and 2, assemble, broadcast a valid stagenet transaction; verify-share and blame paths exercised with an injected faulty signer. The fund-moving milestone.
- M5. Backup and restore. W5 plus W6 plus W3: replicate to all three sinks, then non-interactively restore a lost device, and rebuild a lost-seed member via a fresh DKG.
- M6. Daemon and API. W8 wires all of the above behind the actor and exposes REST and MCP; full end-to-end through the API.
C3. Failure-mode catalog and risk ranking¶↑
Ordered by where a bug loses money or privacy. Each maps to the workstream that owns it.
- 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
SpendAuthorityboundary 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. - 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.
- 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++.
- Bulletproofs+ proving and exact consensus serialization (W1). A byte mismatch is rejected by the daemon. Provided by monero-oxide; do not hand-roll.
- 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.
- DKG blame and signer attribution (W2). Treating the happy path as the only outcome leaves the group stuck and unable to name a saboteur.
- MLS epoch desync (W3). A missed Commit strands a member; needs ordered redelivery and persisted epoch state.
- 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.
- 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.
- Rev pinning (resolved). monero-oxide has no usable crates.io release (the 0.0.1 entries are placeholders); pin a git rev of
monero-oxide/monero-oxidemain(or its FCMP++ branch once that is the target).modular-frost0.11.0 or serainext, notdevelop(0.10.1).dkg0.6.1. Field names likeSendErrorandFeeRatevariants must still be checked against the pinned rev. Affects W1, W2. - Commitment-mask
z(resolved). Not threshold-shared. Only the spend-key sharexgoes through the DKG and FROST.zis a known per-transaction value supplied viaClsagMultisigMaskSender, folded in once at aggregation (W2). TheSigningSessioncontract threads onlyx. - Shared view key (decided). Derive it from the group key material produced by the DKG, so all members agree deterministically with no extra distribution step. Affects the DKG output contract (W0, W2) and scanning (W1). Note: CARROT introduces outgoing view keys, which the FCMP++ target will revisit.
- FCMP++ vs CLSAG (decided, A1). Target the post-fork FCMP++/CARROT FROST-GSP path. Build clients and the proof-system-independent backend first; defer the signing core until FCMP++ is clean. CLSAG is the interim/reference and fallback.
SpendAuthoritymust abstract address derivation and signing fully (W0, W1, W2). - UI/MCP and approval (decided, W8). UI and MCP are co-equal over one
WalletCommandset; in non-headless mode the GUI mirrors the agent's actions live. The group threshold is the hard bound (an agent controls one share); per-spend human gating is a configurable policy, not a separate MCP restriction. Affects theWalletCommandcontract and W8. - Resharing / membership change (resolved). No reshare code exists: the eVRF DKG (serai
next, unaudited, unpublished) exposes only fresh generation (participate/verify/keys); reshare and refresh are design intent with no callable API. Membership change is a fresh group, the only available option, as the whitepaper fixes (W0, W3). Watch the eVRF DKG; do not foreclose it in the roster and channel contracts. - Auditable mode (decided 2026-06-10). A group may switch from a default private mode into an auditable mode with a persistent public identity, signed statements, and a proof of reserves (W9, whitepaper 8). Going auditable requires n-of-n consensus and sets the group's public alias; the individual attestations afterward run at the normal m-of-n. The identity key is compartmentalized from the spend key, the MLS credentials, and the transport keys; statement signing is plain-Schnorr FROST (build-now, no FCMP++); the threshold zero-knowledge proof of reserves (minimum balance only, no provenance) is the one research-grade piece, likely post-FCMP++. The
GroupMode,mode.switch(alias plus identity key) andattest.*messages, and the mode and attestation commands, are part of the frozen W0 schema from the start; W9 implements their behaviour without changing the contract.
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).