Quellcode durchsuchen

bin/tau: implement RLN;
- same methods as darkirc
- disabled by default
- added rln account managment in tau-python (client) via RPC

x vor 1 Tag
Ursprung
Commit
7c5327a553

+ 21 - 0
bin/tau/tau-python/api.py

@@ -85,3 +85,24 @@ async def export_to(path, server_name, port):
 
 async def import_from(path, server_name, port):
     return await query("import", [path], server_name, int(port))
+
+async def rln_register(account_name, nullifier, trapdoor, user_msg_limit, server_name, port):
+    return await query(
+        "rln_register",
+        [account_name, nullifier, trapdoor, user_msg_limit],
+        server_name,
+        int(port),
+    )
+
+async def rln_info(account_name, server_name, port):
+    params = [account_name] if account_name else []
+    return await query("rln_info", params, server_name, int(port))
+
+async def rln_set(account_name, server_name, port):
+    return await query("rln_set", [account_name], server_name, int(port))
+
+async def rln_deregister(account_name, server_name, port):
+    return await query("rln_deregister", [account_name], server_name, int(port))
+
+async def rln_slash(account_name, server_name, port):
+    return await query("rln_slash", [account_name], server_name, int(port))

+ 56 - 7
bin/tau/tau-python/tau

@@ -47,14 +47,14 @@ async def add_task(task_args, server_name, port):
 
     title = " ".join(title_words)
     if len(title) == 0:
-        print("Error: Title is required")
+        print("Error: Title is required", file=sys.stderr)
         exit(-1)
     task["title"] = title
     if task["desc"] is None:
         task["desc"] = prompt_description_text(task)
     
     if task["desc"].strip() == '':
-        print("Abort adding the task due to empty description.")
+        print("Abort adding the task due to empty description.", file=sys.stderr)
         exit(-1)
     
     if task["rank"] is not None:
@@ -630,6 +630,7 @@ SUBCOMMANDS:
     show       List filtered tasks.
     export     Save current workspace tasks to a path.
     import     Load current workspace tasks from a path.
+    rln        Manage local RLN identities (account).
     help       Show this help text.
 
 Examples:
@@ -670,7 +671,7 @@ Examples:
                 month = sys.argv[2]
                 month_ts = lib.util.month_to_unix(month)
             else:
-                print("error: usage format is: tau archive [MONTH] [ID]")
+                print("error: usage format is: tau archive [MONTH] [ID]", file=sys.stderr)
                 return -1
                 
             archive_refids = await api.get_archive_ref_ids(month_ts, server_name, port)
@@ -724,7 +725,7 @@ Examples:
                 if (errc := await show_archive_task(arefid, month_ts, server_name, port)) < 0:
                     return errc
             else:
-                print("error: month must be of format MMYY")
+                print("error: month must be of format MMYY", file=sys.stderr)
                 return -1
         else:
             month_ts = lib.util.month_to_unix()
@@ -740,10 +741,10 @@ Examples:
         return 0
     elif sys.argv[1] == "switch":
         if not len(sys.argv) == 3:
-            print("Error: you must provide workspace name")
+            print("Error: you must provide workspace name", file=sys.stderr)
             return 0
         if not await api.switch_workspace(sys.argv[2], server_name, port):
-            print(f"Error: Workspace \"{sys.argv[2]}\" is not configured.")
+            print(f"Error: Workspace \"{sys.argv[2]}\" is not configured.", file=sys.stderr)
         else:
             print(f"You are now on \"{sys.argv[2]}\" workspace.")
         return 0
@@ -763,6 +764,54 @@ Examples:
         if await api.import_from(path, server_name, port):
             print(f"Imported tasks successfuly from {path}")
         return 0
+    elif sys.argv[1] == "rln":
+        if len(sys.argv) == 2:
+            print("""USAGE:
+    tau rln register <account_name> <nullifier> <trapdoor> <user_msg_limit>
+    tau rln info [account_name]
+    tau rln set <account_name>
+    tau rln deregister <account_name>
+    tau rln slash <account_name>
+
+Run `taud --gen-rln-identity` to mint fresh secrets for REGISTER.""")
+            return 0
+
+        subcmd = sys.argv[2]
+        args = sys.argv[3:]
+        if subcmd == "register":
+            if len(args) != 4:
+                print("error: usage format is: tau rln register <account_name> <nullifier> <trapdoor> <user_msg_limit>", file=sys.stderr)
+                sys.exit(-1)
+            lines = await api.rln_register(args[0], args[1], args[2], args[3], server_name, port)
+        elif subcmd == "info":
+            account_name = args[0] if args else None
+            lines = await api.rln_info(account_name, server_name, port)
+        elif subcmd == "set":
+            if len(args) != 1:
+                print("error: usage format is: tau rln set <account_name>", file=sys.stderr)
+                sys.exit(-1)
+            lines = await api.rln_set(args[0], server_name, port)
+        elif subcmd == "deregister":
+            if len(args) != 1:
+                print("error: usage format is: tau rln deregister <account_name>", file=sys.stderr)
+                sys.exit(-1)
+            lines = await api.rln_deregister(args[0], server_name, port)
+        elif subcmd == "slash":
+            if len(args) != 2 or args[1] != "CONFIRM":
+                print("error: usage format is: tau rln slash <account_name> CONFIRM", file=sys.stderr)
+                print("WARNING: rln slash is permanent and network-wide.", file=sys.stderr)
+                sys.exit(-1)
+            lines = await api.rln_slash(args[0], server_name, port)
+        else:
+            print(f"error: unknown rln subcommand '{subcmd}'", file=sys.stderr)
+            sys.exit(-1)
+
+        if isinstance(lines, list):
+            for line in lines:
+                print(line)
+        else:
+            print(lines)
+        return 0
 
     try:
         id = sys.argv[1]
@@ -812,7 +861,7 @@ Examples:
 
     if subcmd == "modify":
         if not args:
-            print("Error: modify subcommand must have at least one argument.")
+            print("Error: modify subcommand must have at least one argument.", file=sys.stderr)
             exit(-1)
         for rid in refid:
             if (errc := await modify_task(rid, args, server_name, port)) < 0:

+ 4 - 0
bin/tau/taud/src/error.rs

@@ -17,6 +17,7 @@
  */
 
 use darkfi::rpc::jsonrpc::{ErrorCode, JsonError, JsonResponse, JsonResult};
+use sled_overlay::sled;
 use tinyjson::JsonValue;
 
 #[derive(Debug, thiserror::Error)]
@@ -37,6 +38,8 @@ pub enum TaudError {
     DecryptionError(String),
     #[error("IO Error: `{0}`")]
     IoError(String),
+    #[error("Sled error: `{0}`")]
+    Sled(#[from] sled::Error),
 }
 
 pub type TaudResult<T> = std::result::Result<T, TaudError>;
@@ -76,6 +79,7 @@ pub fn to_json_result(res: TaudResult<JsonValue>, id: i64) -> JsonResult {
                 JsonError::new(ErrorCode::InternalError, Some(e.to_string()), id).into()
             }
             TaudError::IoError(e) => JsonError::new(ErrorCode::InternalError, Some(e), id).into(),
+            TaudError::Sled(e) => JsonError::new(ErrorCode::InternalError, Some(e.to_string()), id).into(),
         },
     }
 }

+ 33 - 0
bin/tau/taud/src/genesis_commits.rs

@@ -0,0 +1,33 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use darkfi_sdk::{crypto::pasta_prelude::PrimeField, pasta::pallas};
+
+/// Return Taud's configured pregenerated RLN commitment set.
+pub fn pregenerated_identity_commitments() -> Vec<[u8; 32]> {
+    TAUD_GENESIS_COMMITMENTS_REPR.to_vec()
+}
+
+/// Check whether an RLN commitment belongs to Taud's pregenerated set.
+pub fn is_pregenerated_commitment(commitment: &pallas::Base) -> bool {
+    TAUD_GENESIS_COMMITMENTS_REPR.contains(&commitment.to_repr())
+}
+
+/// Taud's pregenerated RLN commitment set, represented as an array of 32-byte arrays.
+/// TODO: Populate this with the actual pregenerated commitments once they are available.
+pub const TAUD_GENESIS_COMMITMENTS_REPR: &[[u8; 32]] = &[];

+ 519 - 3
bin/tau/taud/src/jsonrpc.rs

@@ -24,12 +24,16 @@ use std::{
 };
 
 use async_trait::async_trait;
-use smol::lock::{Mutex, MutexGuard};
+use sled_overlay::sled;
+use smol::lock::{Mutex, MutexGuard, RwLock};
 use tinyjson::JsonValue;
 use tracing::{debug, info, warn};
 
 use darkfi::{
-    event_graph::EventGraphPtr,
+    event_graph::{
+        rln::{prepare_slash_proof_request, RLNNode, RlnProver, SlashBlob, GENESIS_USER_MSG_LIMIT},
+        Event, EventGraphPtr,
+    },
     net,
     rpc::{
         jsonrpc::{ErrorCode, JsonError, JsonRequest, JsonResult, JsonSubscriber},
@@ -37,19 +41,26 @@ use darkfi::{
         server::RequestHandler,
     },
     system::StoppableTaskPtr,
-    util::{path::expand_path, time::Timestamp},
+    util::{memory::log_memory, path::expand_path, time::Timestamp},
     Error,
 };
 
+use darkfi_sdk::{crypto::pasta_prelude::PrimeField, pasta::pallas};
+use darkfi_serial::{deserialize_async, serialize_async};
+
 use taud::{
     error::{to_json_result, TaudError, TaudResult},
+    genesis_commits::is_pregenerated_commitment,
     month_tasks::MonthTasks,
+    rln::{RlnIdentity, ACCOUNTS_DB_PREFIX, ACCOUNTS_DEFAULT_TREE, ACCOUNTS_KEY_RLN_IDENTITY},
     task_info::{Comment, TaskInfo},
     util::set_event,
 };
 
 use crate::Workspace;
 
+const MAX_ACCOUNT_NAME_LEN: usize = 24;
+
 pub struct JsonRpcInterface {
     dataset_path: PathBuf,
     notify_queue_sender: smol::channel::Sender<TaskInfo>,
@@ -61,6 +72,8 @@ pub struct JsonRpcInterface {
     dnet_sub: JsonSubscriber,
     deg_sub: JsonSubscriber,
     rpc_connections: Mutex<HashSet<StoppableTaskPtr>>,
+    sled_db: sled::Db,
+    rln_identity: Arc<RwLock<Option<RlnIdentity>>>,
 }
 
 #[async_trait]
@@ -81,6 +94,12 @@ impl RequestHandler<()> for JsonRpcInterface {
             "fetch_deactive_tasks" => self.fetch_deactive_tasks(req.params).await,
             "fetch_archive_task" => self.fetch_archive_task(req.params).await,
 
+            "rln_register" => self.rln_register(req.params).await,
+            "rln_info" => self.rln_info(req.params).await,
+            "rln_set" => self.rln_set(req.params).await,
+            "rln_deregister" => self.rln_deregister(req.params).await,
+            "rln_slash" => self.rln_slash(req.params).await,
+
             "ping" => return self.pong(req.id, req.params).await,
             "dnet.subscribe_events" => return self.dnet_subscribe_events(req.id, req.params).await,
             "dnet.switch" => self.dnet_switch(req.params).await,
@@ -119,6 +138,8 @@ impl JsonRpcInterface {
         event_graph: EventGraphPtr,
         dnet_sub: JsonSubscriber,
         deg_sub: JsonSubscriber,
+        sled_db: sled::Db,
+        rln_identity: Arc<RwLock<Option<RlnIdentity>>>,
     ) -> Self {
         let workspace = Mutex::new(workspace);
         Self {
@@ -132,6 +153,8 @@ impl JsonRpcInterface {
             rpc_connections: Mutex::new(HashSet::new()),
             dnet_sub,
             deg_sub,
+            sled_db,
+            rln_identity,
         }
     }
 
@@ -789,4 +812,497 @@ impl JsonRpcInterface {
 
         Ok(task)
     }
+
+    // RPCAPI:
+    // Register a pregenerated RLN identity under a local account name.
+    // Pregenerated identities are already bootstrapped into the static
+    // DAG; this command does not broadcast a public free-tier
+    // registration proof. The first account registered also becomes the
+    // active one.
+    //
+    // --> {"jsonrpc": "2.0", "method": "rln_register",
+    //      "params": [account_name, nullifier, trapdoor, user_msg_limit], "id": 1}
+    // <-- {"jsonrpc": "2.0", "result": ["...", ...], "id": 1}
+    async fn rln_register(&self, params: JsonValue) -> TaudResult<JsonValue> {
+        if !self.event_graph.rln_enabled() {
+            return Ok(strings_to_json(vec![
+                "RLN is disabled; registration is not required.".to_string()
+            ]))
+        }
+
+        let params = params.get::<Vec<JsonValue>>().unwrap();
+        debug!(target: "tau", "JsonRpc::rln_register() params {params:?}");
+
+        if params.len() != 4 ||
+            !params[0].is_string() ||
+            !params[1].is_string() ||
+            !params[2].is_string() ||
+            !params[3].is_string()
+        {
+            return Err(TaudError::InvalidData(
+                "len of params should be 4 (account_name, nullifier, trapdoor, user_msg_limit)"
+                    .into(),
+            ))
+        }
+
+        let account_name = params[0].get::<String>().unwrap();
+        let identity_nullifier = params[1].get::<String>().unwrap();
+        let identity_trapdoor = params[2].get::<String>().unwrap();
+        let user_msg_limit_str = params[3].get::<String>().unwrap();
+
+        // Reserved name. We use `default` for the mirror tree.
+        if !is_valid_account_name(account_name) {
+            return Ok(strings_to_json(vec!["Invalid account name.".to_string()]))
+        }
+
+        // Parse user_msg_limit defensively so a typo doesn't tear the
+        // daemon down.
+        let user_msg_limit: u64 = match user_msg_limit_str.parse() {
+            Ok(v) => v,
+            Err(_) => {
+                return Ok(strings_to_json(vec![
+                    "Invalid user_msg_limit: must be a positive integer.".to_string(),
+                ]))
+            }
+        };
+        if user_msg_limit == 0 {
+            return Ok(strings_to_json(vec![
+                "Invalid user_msg_limit: must be at least 1.".to_string()
+            ]))
+        }
+
+        // Parse the secrets, gracefully rejecting malformed base58.
+        let identity_nullifier = match parse_pallas_b58(identity_nullifier) {
+            Some(v) => v,
+            None => return Ok(strings_to_json(vec!["Invalid identity_nullifier.".to_string()])),
+        };
+        let identity_trapdoor = match parse_pallas_b58(identity_trapdoor) {
+            Some(v) => v,
+            None => return Ok(strings_to_json(vec!["Invalid identity_trapdoor.".to_string()])),
+        };
+
+        // `last_epoch` is initialised to 0 deterministically - the first
+        // persisted send reservation will detect the rollover to the
+        // current wall-clock epoch.
+        let new_rln_identity = RlnIdentity {
+            nullifier: identity_nullifier,
+            trapdoor: identity_trapdoor,
+            user_message_limit: user_msg_limit,
+            message_id: 0,
+            last_epoch: 0,
+        };
+
+        if !is_pregenerated_commitment(&new_rln_identity.commitment()) {
+            return Ok(strings_to_json(vec![
+                "Registration is currently limited to pregenerated identities.".to_string(),
+            ]))
+        }
+
+        if user_msg_limit != GENESIS_USER_MSG_LIMIT {
+            return Ok(strings_to_json(vec![format!(
+                "Genesis account must use user_msg_limit={}",
+                GENESIS_USER_MSG_LIMIT
+            )]))
+        }
+
+        // Open the per-account sled tree only after the identity has
+        // passed the pregenerated-admission checks.
+        let db = self.sled_db.open_tree(format!("{ACCOUNTS_DB_PREFIX}{account_name}"))?;
+        if !db.is_empty() {
+            return Ok(strings_to_json(vec!["This account name is already registered.".to_string()]))
+        }
+
+        // Store account.
+        db.insert(ACCOUNTS_KEY_RLN_IDENTITY, serialize_async(&new_rln_identity).await)?;
+
+        // First-ever registration also becomes the active one.
+        let became_active = self.rln_identity.read().await.is_none();
+        if became_active {
+            let db_default = self.sled_db.open_tree(ACCOUNTS_DEFAULT_TREE)?;
+            db_default
+                .insert(ACCOUNTS_KEY_RLN_IDENTITY, serialize_async(&new_rln_identity).await)?;
+            *self.rln_identity.write().await = Some(new_rln_identity);
+        }
+
+        let mut replies = vec![format!("Successfully registered account \"{account_name}\"")];
+        if became_active {
+            replies.push(format!("\"{account_name}\" is now the active identity."));
+        } else {
+            replies.push(format!("Use `rln_set {account_name}` to make this the active identity."));
+        }
+
+        Ok(strings_to_json(replies))
+    }
+
+    // RPCAPI:
+    // List registered accounts (with active marker), or dump the
+    // secrets for a single account.
+    //
+    // --> {"jsonrpc": "2.0", "method": "rln_info", "params": [], "id": 1}
+    // <-- {"jsonrpc": "2.0", "result": ["...", ...], "id": 1}
+    // --> {"jsonrpc": "2.0", "method": "rln_info", "params": [account_name], "id": 1}
+    async fn rln_info(&self, params: JsonValue) -> TaudResult<JsonValue> {
+        let params = params.get::<Vec<JsonValue>>().unwrap();
+        debug!(target: "tau", "JsonRpc::rln_info() params {params:?}");
+
+        if params.len() > 1 {
+            return Err(TaudError::InvalidData("rln_info takes at most one account_name".into()))
+        }
+
+        let account_name = params.first().and_then(|p| p.get::<String>()).cloned();
+        if let Some(account_name) = account_name {
+            return Ok(strings_to_json(self.rln_info_account(&account_name).await?))
+        }
+
+        // The active identity's commitment is what we compare against.
+        let active_commitment = self.rln_identity.read().await.as_ref().map(|id| id.commitment());
+
+        let mut accounts: Vec<(String, RlnIdentity)> = Vec::new();
+        for raw in self.sled_db.tree_names() {
+            let bytes: &[u8] = raw.as_ref();
+            let Ok(name) = std::str::from_utf8(bytes) else { continue };
+            // Skip the `default` mirror tree and anything that isn't an
+            // account tree.
+            let Some(account_name) = name.strip_prefix(ACCOUNTS_DB_PREFIX) else { continue };
+            if !is_valid_account_name(account_name) {
+                continue
+            }
+
+            let tree = self.sled_db.open_tree(name)?;
+            let Some(blob) = tree.get(ACCOUNTS_KEY_RLN_IDENTITY)? else { continue };
+            let Ok(identity): std::result::Result<RlnIdentity, _> = deserialize_async(&blob).await
+            else {
+                continue
+            };
+
+            accounts.push((account_name.to_string(), identity));
+        }
+
+        if accounts.is_empty() {
+            return Ok(strings_to_json(vec![
+                "No registered accounts. Use rln_register to create one.".to_string(),
+            ]))
+        }
+
+        accounts.sort_by(|a, b| a.0.cmp(&b.0));
+
+        let mut lines = vec!["Registered accounts (* = active):".to_string()];
+        for (name, id) in &accounts {
+            let active_mark = if Some(id.commitment()) == active_commitment { "*" } else { " " };
+            let commitment_b58 = bs58::encode(id.commitment().to_repr()).into_string();
+            lines.push(format!(
+                "  {active_mark} {name}  limit={}  commitment={commitment_b58}",
+                id.user_message_limit,
+            ));
+        }
+        lines.push(
+            "Use `rln_info <account_name>` to show that account's secrets (rln_register args)."
+                .to_string(),
+        );
+
+        Ok(strings_to_json(lines))
+    }
+
+    /// `rln_info <account_name>`. Dumps the secrets so the user can
+    /// reconstruct the identity elsewhere.
+    async fn rln_info_account(&self, account_name: &str) -> TaudResult<Vec<String>> {
+        if !is_valid_account_name(account_name) {
+            return Ok(vec!["Invalid account name.".to_string()])
+        }
+
+        let tree_name = format!("{ACCOUNTS_DB_PREFIX}{account_name}");
+        let tree = self.sled_db.open_tree(&tree_name)?;
+        let Some(blob) = tree.get(ACCOUNTS_KEY_RLN_IDENTITY)? else {
+            return Ok(vec![format!("No such account: \"{account_name}\"")])
+        };
+        let identity: RlnIdentity = match deserialize_async(&blob).await {
+            Ok(v) => v,
+            Err(_) => {
+                return Ok(vec![format!(
+                    "Account \"{account_name}\" exists but its data is corrupted."
+                )])
+            }
+        };
+
+        let nullifier_b58 = bs58::encode(identity.nullifier.to_repr()).into_string();
+        let trapdoor_b58 = bs58::encode(identity.trapdoor.to_repr()).into_string();
+        let commitment_b58 = bs58::encode(identity.commitment().to_repr()).into_string();
+
+        let active_commitment = self.rln_identity.read().await.as_ref().map(|id| id.commitment());
+        let is_active = Some(identity.commitment()) == active_commitment;
+
+        let mut lines = vec![format!(
+            "Account \"{account_name}\"{}:",
+            if is_active { " (ACTIVE)" } else { "" },
+        )];
+        lines.push(format!("  commitment       = {commitment_b58}"));
+        lines.push(format!("  user_msg_limit   = {}", identity.user_message_limit));
+        lines.push("  --- secrets below; treat as a password ---".to_string());
+        lines.push(format!("  nullifier        = {nullifier_b58}"));
+        lines.push(format!("  trapdoor         = {trapdoor_b58}"));
+        lines.push("To re-register on another node, run:".to_string());
+        lines.push(format!(
+            "  tau rln register {account_name} {nullifier_b58} {trapdoor_b58} {limit}",
+            limit = identity.user_message_limit,
+        ));
+
+        Ok(lines)
+    }
+
+    // RPCAPI:
+    // Swap the active identity to the named account. The choice is
+    // persisted (next restart will load the same one) and takes effect
+    // for the next outbound task event.
+    //
+    // --> {"jsonrpc": "2.0", "method": "rln_set", "params": [account_name], "id": 1}
+    // <-- {"jsonrpc": "2.0", "result": ["...", ...], "id": 1}
+    async fn rln_set(&self, params: JsonValue) -> TaudResult<JsonValue> {
+        if !self.event_graph.rln_enabled() {
+            return Ok(strings_to_json(vec!["RLN is disabled; rln_set has no effect.".to_string()]))
+        }
+
+        let params = params.get::<Vec<JsonValue>>().unwrap();
+        debug!(target: "tau", "JsonRpc::rln_set() params {params:?}");
+
+        if params.len() != 1 || !params[0].is_string() {
+            return Err(TaudError::InvalidData("len of params should be 1".into()))
+        }
+
+        let account_name = params[0].get::<String>().unwrap();
+        if !is_valid_account_name(account_name) {
+            return Ok(strings_to_json(vec!["Invalid account name.".to_string()]))
+        }
+
+        let tree_name = format!("{ACCOUNTS_DB_PREFIX}{account_name}");
+        let tree = self.sled_db.open_tree(&tree_name)?;
+        let Some(blob) = tree.get(ACCOUNTS_KEY_RLN_IDENTITY)? else {
+            return Ok(strings_to_json(vec![
+                format!("No such account: \"{account_name}\""),
+                "Use rln_info to list registered accounts.".to_string(),
+            ]))
+        };
+        let identity: RlnIdentity = match deserialize_async(&blob).await {
+            Ok(v) => v,
+            Err(_) => {
+                return Ok(strings_to_json(vec![format!(
+                    "Account \"{account_name}\" data is corrupted."
+                )]))
+            }
+        };
+
+        // No-op if it's already active.
+        let already_active = match self.rln_identity.read().await.as_ref() {
+            Some(active) => active.commitment() == identity.commitment(),
+            None => false,
+        };
+        if already_active {
+            return Ok(strings_to_json(vec![format!(
+                "\"{account_name}\" is already the active identity."
+            )]))
+        }
+
+        // Persist the choice. We write the freshly-loaded blob (not the
+        // in-memory identity, which would have stale counter state if it
+        // were the previously-active one) because the default tree is
+        // meant to mirror an account tree exactly.
+        let db_default = self.sled_db.open_tree(ACCOUNTS_DEFAULT_TREE)?;
+        db_default.insert(ACCOUNTS_KEY_RLN_IDENTITY, blob.as_ref())?;
+
+        *self.rln_identity.write().await = Some(identity);
+
+        Ok(strings_to_json(vec![
+            format!("Active identity is now \"{account_name}\"."),
+            "If you have used this identity recently from another node, wait one RLN epoch \
+             (10 minutes) before sending to avoid a counter clash."
+                .to_string(),
+        ]))
+    }
+
+    // RPCAPI:
+    // Remove an account from local storage. The on-network RLN
+    // registration is permanent; this only forgets the account locally.
+    // Refuses to drop the active account.
+    //
+    // --> {"jsonrpc": "2.0", "method": "rln_deregister", "params": [account_name], "id": 1}
+    // <-- {"jsonrpc": "2.0", "result": ["...", ...], "id": 1}
+    async fn rln_deregister(&self, params: JsonValue) -> TaudResult<JsonValue> {
+        let params = params.get::<Vec<JsonValue>>().unwrap();
+        debug!(target: "tau", "JsonRpc::rln_deregister() params {params:?}");
+
+        if params.len() != 1 || !params[0].is_string() {
+            return Err(TaudError::InvalidData("len of params should be 1".into()))
+        }
+
+        let account_name = params[0].get::<String>().unwrap();
+        if !is_valid_account_name(account_name) {
+            return Ok(strings_to_json(vec!["Invalid account name.".to_string()]))
+        }
+
+        let tree_name = format!("{ACCOUNTS_DB_PREFIX}{account_name}");
+        let tree = self.sled_db.open_tree(&tree_name)?;
+        let Some(blob) = tree.get(ACCOUNTS_KEY_RLN_IDENTITY)? else {
+            return Ok(strings_to_json(vec![format!("No such account: \"{account_name}\"")]))
+        };
+        let identity: RlnIdentity = match deserialize_async(&blob).await {
+            Ok(v) => v,
+            Err(_) => {
+                // Corrupted account: allow the user to reclaim the tree
+                // name, but err on the safe side if there IS an active one.
+                if self.rln_identity.read().await.is_some() {
+                    return Ok(strings_to_json(vec![format!(
+                        "Account \"{account_name}\" data is corrupted; refusing to \
+                         auto-deregister while another identity is active. rln_set to \
+                         a clean account first, then retry."
+                    )]))
+                }
+                self.sled_db.drop_tree(&tree_name)?;
+                return Ok(strings_to_json(vec![format!(
+                    "Dropped corrupted account \"{account_name}\"."
+                )]))
+            }
+        };
+
+        // Refuse if active.
+        if let Some(active) = self.rln_identity.read().await.as_ref() {
+            if active.commitment() == identity.commitment() {
+                return Ok(strings_to_json(vec![
+                    format!("\"{account_name}\" is the active identity; refusing to deregister."),
+                    "Use `rln_set <other_account>` first to switch away.".to_string(),
+                ]))
+            }
+        }
+
+        self.sled_db.drop_tree(&tree_name)?;
+
+        Ok(strings_to_json(vec![format!("Successfully deregistered account \"{account_name}\"")]))
+    }
+
+    // RPCAPI:
+    // Permanently retire an account on the network. Publishes a slash
+    // event into the static DAG; once accepted by peers the identity is
+    // removed from the SMT network-wide and CANNOT be re-registered. The
+    // slash blob contains the identity_secret_hash in plaintext, so the
+    // secret becomes world-readable on the wire.
+    //
+    // --> {"jsonrpc": "2.0", "method": "rln_slash", "params": [account_name], "id": 1}
+    // <-- {"jsonrpc": "2.0", "result": ["...", ...], "id": 1}
+    async fn rln_slash(&self, params: JsonValue) -> TaudResult<JsonValue> {
+        if !self.event_graph.rln_enabled() {
+            return Ok(strings_to_json(vec![
+                "RLN is disabled; rln_slash is unavailable.".to_string()
+            ]))
+        }
+
+        let params = params.get::<Vec<JsonValue>>().unwrap();
+        debug!(target: "tau", "JsonRpc::rln_slash() params {params:?}");
+
+        if params.len() != 1 || !params[0].is_string() {
+            return Err(TaudError::InvalidData("len of params should be 1".into()))
+        }
+
+        let account_name = params[0].get::<String>().unwrap();
+        if !is_valid_account_name(account_name) {
+            return Ok(strings_to_json(vec!["Invalid account name.".to_string()]))
+        }
+
+        let tree_name = format!("{ACCOUNTS_DB_PREFIX}{account_name}");
+        let tree = self.sled_db.open_tree(&tree_name)?;
+        let Some(blob) = tree.get(ACCOUNTS_KEY_RLN_IDENTITY)? else {
+            return Ok(strings_to_json(vec![format!("No such account: \"{account_name}\"")]))
+        };
+        let identity: RlnIdentity = match deserialize_async(&blob).await {
+            Ok(v) => v,
+            Err(_) => {
+                return Ok(strings_to_json(vec![format!(
+                    "Account \"{account_name}\" data is corrupted."
+                )]))
+            }
+        };
+
+        // Refuse if active.
+        if let Some(active) = self.rln_identity.read().await.as_ref() {
+            if active.commitment() == identity.commitment() {
+                return Ok(strings_to_json(vec![
+                    format!("\"{account_name}\" is the active identity; refusing to slash."),
+                    "Use `rln_set <other_account>` first if you genuinely want to slash \
+                     this identity."
+                        .to_string(),
+                ]))
+            }
+        }
+
+        // Refuse while unsynced. The slash proof's public input includes
+        // the current SMT root, which peers verify against their own
+        // historical-roots table.
+        let evgr = &self.event_graph;
+        if !evgr.is_synced() {
+            return Ok(strings_to_json(vec![
+                "Cannot rln_slash while the local DAG is unsynced.".to_string(),
+                "Wait for sync to complete and try again.".to_string(),
+            ]))
+        }
+
+        // Build the slash proof. The request contains identity_secret_hash
+        // (NOT the raw nullifier+trapdoor pair) because that's what SSS
+        // would recover in the misbehavior path.
+        let identity_secret_hash = identity.identity_secret_hash();
+        let request = {
+            let id_state = evgr.rln_identity_state()?.read().await;
+            prepare_slash_proof_request(identity_secret_hash, &id_state)
+        };
+        let root = request.merkle_root;
+
+        log_memory("before slash proving");
+        let proof = evgr.rln_zk_keys()?.prove_slash(request).await?.proof;
+        log_memory("after slash proving");
+
+        let slash_blob = SlashBlob { proof, identity_secret_hash, merkle_root: root };
+        let blob_bytes = serialize_async(&slash_blob).await;
+
+        let rln_node = RLNNode::Slashing(identity.commitment());
+        let event = Event::new_static(serialize_async(&rln_node).await, evgr).await?;
+
+        // Commit through the verified static-event pipeline so durable event
+        // storage stays ahead of RLN side tables, while subscribers still see
+        // the event only after the local RLN state has been updated.
+        evgr.commit_verified_static_event(&event, &blob_bytes, &rln_node).await?;
+        evgr.static_broadcast(event, blob_bytes).await?;
+
+        // Drop the local account tree. The on-network slash makes the
+        // account unusable anyway.
+        self.sled_db.drop_tree(&tree_name)?;
+
+        Ok(strings_to_json(vec![
+            format!("SLASHED \"{account_name}\". The identity is permanently retired."),
+            "The slash event has been broadcast to peers; once propagated, the \
+             commitment is removed from the network's identity tree."
+                .to_string(),
+            "Local account state has also been dropped.".to_string(),
+        ]))
+    }
+}
+
+fn strings_to_json(lines: Vec<String>) -> JsonValue {
+    JsonValue::Array(lines.into_iter().map(JsonValue::String).collect())
+}
+
+fn is_account_name_char(byte: u8) -> bool {
+    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')
+}
+
+/// Return true when a local account name is safe as a sled tree suffix.
+fn is_valid_account_name(account_name: &str) -> bool {
+    account_name != "default" &&
+        !account_name.is_empty() &&
+        account_name.len() <= MAX_ACCOUNT_NAME_LEN &&
+        account_name.bytes().all(is_account_name_char)
+}
+
+/// Decode a base58-encoded `pallas::Base` scalar. Returns `None` for
+/// any malformed input rather than panicking - this is called on
+/// user-supplied RPC parameters.
+fn parse_pallas_b58(s: &str) -> Option<pallas::Base> {
+    let bytes = bs58::decode(s).into_vec().ok()?;
+    let arr: [u8; 32] = bytes.try_into().ok()?;
+    pallas::Base::from_repr(arr).into_option()
 }

+ 2 - 0
bin/tau/taud/src/lib.rs

@@ -17,6 +17,8 @@
  */
 
 pub mod error;
+pub mod genesis_commits;
 pub mod month_tasks;
+pub mod rln;
 pub mod task_info;
 pub mod util;

+ 322 - 20
bin/tau/taud/src/main.rs

@@ -17,10 +17,10 @@
  */
 
 use std::{
-    collections::BTreeMap,
+    collections::{BTreeMap, HashMap},
     env,
     ffi::CString,
-    fs::{create_dir_all, remove_dir_all},
+    fs::{create_dir_all, remove_dir_all, File},
     io::{stdin, Write},
     str::FromStr,
     sync::{atomic::Ordering, Arc, OnceLock},
@@ -41,7 +41,7 @@ use sled_overlay::sled;
 use smol::{fs, stream::StreamExt};
 use structopt_toml::StructOptToml;
 use tinyjson::JsonValue;
-use tracing::{debug, error, info};
+use tracing::{debug, error, info, warn};
 
 use darkfi::{
     async_daemonize,
@@ -60,6 +60,7 @@ use darkfi::{
 };
 
 use darkfi_sdk::crypto::{
+    pasta_prelude::PrimeField,
     schnorr::{SchnorrPublic, SchnorrSecret, Signature},
     Keypair, PublicKey,
 };
@@ -89,11 +90,33 @@ const TAUD_GENESIS_CONTENTS: &[u8] = b"taud-v1";
 /// 24-hour history window. Older events are evicted from sled.
 const TAUD_MAX_DAGS: usize = 1;
 
+/// Sled cache capacity multiplier.
+const BYTES_PER_MIB: u64 = 1024 * 1024;
+
+/// Per-epoch limit printed by `--gen-rln-identity`.
+fn generated_rln_identity_user_msg_limit() -> u64 {
+    darkfi::event_graph::rln::GENESIS_USER_MSG_LIMIT
+}
+
+fn sled_cache_capacity_bytes(name: &str, cache_mb: u64) -> Result<u64> {
+    if cache_mb == 0 {
+        return Err(Error::Custom(format!("{name} must be greater than 0")))
+    }
+
+    cache_mb
+        .checked_mul(BYTES_PER_MIB)
+        .ok_or_else(|| Error::Custom(format!("{name} overflows bytes")))
+}
+
 mod jsonrpc;
 mod settings;
 
 use taud::{
     error::{TaudError, TaudResult},
+    rln::{
+        load_default_rln_identity, reserve_rln_message_id_in_store, RlnIdentity,
+        RlnMessageReservation,
+    },
     task_info::{TaskEvent, TaskInfo},
     util::pipe_write,
 };
@@ -325,6 +348,7 @@ async fn start_sync_loop(
     settings: Args,
     p2p: P2pPtr,
     seen: OnceLock<sled::Tree>,
+    rln_identity: Arc<smol::lock::RwLock<Option<RlnIdentity>>>,
 ) -> TaudResult<()> {
     let incoming = event_graph.event_pub.clone().subscribe().await;
 
@@ -350,12 +374,49 @@ async fn start_sync_loop(
                     let dag_name = current_genesis.header.timestamp.to_string();
                     drop(current_genesis);
 
-                    if let Err(e) = event_graph.insert_signal_with_blob(&event, &[], &dag_name).await {
+                    // Build the RLN signal blob before touching the local DAG when RLN
+                    // is enabled. With RLN disabled, outbound events deliberately carry
+                    // no proof blob.
+                    let blob = if event_graph.rln_enabled() {
+                        let (rln_identity, mid) = {
+                            let mut active = rln_identity.write().await;
+                            match reserve_rln_message_id_in_store(
+                                &sled_db,
+                                &mut active,
+                                event.header.timestamp,
+                            )
+                            .await?
+                            {
+                                RlnMessageReservation::Reserved { identity, message_id } => {
+                                    (identity, message_id)
+                                }
+                                RlnMessageReservation::MissingIdentity => {
+                                    warn!(target: "taud", "No RLN identity registered; refusing to send. Run `tau rln register ...` to register.");
+                                    continue
+                                }
+                                RlnMessageReservation::BudgetExhausted => {
+                                    warn!(target: "taud", "RLN message budget exhausted for this epoch; dropping message to avoid slash");
+                                    continue
+                                }
+                            }
+                        };
+                        match rln_identity.create_signal(&event, mid, &event_graph).await {
+                            Ok(blob) => serialize_async(&blob).await,
+                            Err(e) => {
+                                error!(target: "taud", "Failed creating RLN signal proof: {e}");
+                                continue
+                            }
+                        }
+                    } else {
+                        Vec::new()
+                    };
+
+                    if let Err(e) = event_graph.insert_signal_with_blob(&event, &blob, &dag_name).await {
                         error!(target: "taud", "Failed inserting new event to DAG: {e}");
                     } else {
-                        // Otherwise, broadcast it. Taud runs EventGraph with RLN disabled,
-                        // so the blob is intentionally empty.
-                        if let Err(e) = p2p.broadcast(&EventPut(event, vec![])).await {
+                        // Otherwise, broadcast it. Taud runs EventGraph with RLN disabled
+                        // by default, so the blob is empty unless RLN was enabled.
+                        if let Err(e) = p2p.broadcast(&EventPut(event, blob)).await {
                             error!(target: "taud", "Event broadcast was not admitted: {e}");
                         }
                     }
@@ -448,6 +509,8 @@ async fn on_receive_task(
         }
 
         task.save(&datastore_path)?;
+
+        break
     }
     Ok(())
 }
@@ -459,6 +522,87 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
     let nickname =
         if settings.nickname.is_some() { settings.nickname.clone() } else { env::var("USER").ok() };
 
+    if settings.gen_rln_identity {
+        let identity = RlnIdentity::new(&mut OsRng);
+        let nullifier = bs58::encode(identity.nullifier.to_repr()).into_string();
+        let trapdoor = bs58::encode(identity.trapdoor.to_repr()).into_string();
+        // This value is part of the RLN commitment. It must match
+        // the genesis budget used for pregenerated identities.
+        let user_msg_limit = generated_rln_identity_user_msg_limit();
+
+        println!("Generated a fresh RLN identity.\n");
+        println!(
+            "Current Taud registration accepts only identities whose commitments are in \
+             the configured pregenerated set. Use this output for a genesis bundle or future \
+             staked-registration testing; it will not register unless its commitment is \
+             pregenerated.\n"
+        );
+        println!("Local account import command:\n");
+        println!("  tau rln register <account_name> {nullifier} {trapdoor} {user_msg_limit}\n");
+        println!(
+            "Replace <account_name> with any local label you like (\"alice\", \"throwaway\", etc)."
+        );
+        println!(
+            "Do not change user_msg_limit: it is part of the RLN commitment and must be \
+             GENESIS_USER_MSG_LIMIT ({user_msg_limit}) for pregenerated genesis identities."
+        );
+        println!(
+            "Keep the nullifier and trapdoor secret - they ARE the identity. \
+             A `taud --gen-rln-identity` run is NOT idempotent; treat the \
+             output like a freshly-minted password."
+        );
+        return Ok(())
+    }
+
+    if let Some(n_identities) = settings.gen_genesis_rln_identities {
+        // We'll generate n_identities and hold them in a map
+        // `k=commitment, v=(nullifier, trapdoor, used)`
+        // We'll export the commitments to be used in the genesis event,
+        // and the rest as a JSON file.
+        let mut identities_map = HashMap::new();
+        for _ in 0..n_identities {
+            let identity = RlnIdentity::new(&mut OsRng);
+            let commitment = identity.commitment();
+            identities_map.insert(
+                commitment.to_repr(),
+                (identity.nullifier.to_repr(), identity.trapdoor.to_repr(), false),
+            );
+        }
+
+        let mut commits = String::from(
+            r#"
+use darkfi_sdk::{crypto::pasta_prelude::PrimeField, pasta::pallas};
+
+/// Return Taud's configured pregenerated RLN commitment set.
+pub fn pregenerated_identity_commitments() -> Vec<[u8; 32]> {
+    TAUD_GENESIS_COMMITMENTS_REPR.to_vec()
+}
+
+/// Check whether an RLN commitment belongs to Taud's pregenerated set.
+pub fn is_pregenerated_commitment(commitment: &pallas::Base) -> bool {
+    TAUD_GENESIS_COMMITMENTS_REPR.contains(&commitment.to_repr())
+}
+
+pub const TAUD_GENESIS_COMMITMENTS_REPR: &[[u8; 32]] = &[
+"#,
+        );
+
+        for commitment in identities_map.keys() {
+            commits.push_str(&format!("{:?},\n", commitment));
+        }
+
+        commits.push_str("];\n");
+
+        let mut file = File::create("genesis_commits.rs")?;
+        file.write_all(commits.as_bytes())?;
+
+        let mut file = File::create("taud_rln_commits.bin")?;
+        let buf = serialize(&identities_map);
+        file.write_all(&buf)?;
+
+        return Ok(())
+    }
+
     if settings.refresh {
         println!("Removing local data in: {datastore_path:?} (yes/no)? ");
         let mut confirm = String::new();
@@ -542,10 +686,21 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
 
     info!(target: "taud", "Initializing taud node");
 
+    let rln_enabled = settings.rln_enabled.unwrap_or(false);
+
     // Create datastore path if not there already.
     let datastore = expand_path(&settings.datastore)?;
     fs::create_dir_all(&datastore).await?;
 
+    let zk_key_datastore = if rln_enabled {
+        let zk_key_datastore = expand_path(&settings.zk_key_datastore)?;
+        fs::create_dir_all(&zk_key_datastore).await?;
+        Some(zk_key_datastore)
+    } else {
+        info!(target: "taud", "RLN disabled; skipping RLN key datastore setup");
+        None
+    };
+
     let replay_datastore = expand_path(&settings.replay_datastore)?;
     let replay_mode = settings.replay_mode;
     // let fast_mode = settings.fast_mode;
@@ -553,6 +708,27 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
     info!(target: "taud", "Instantiating event DAG");
     let sled_db = sled::open(datastore)?;
 
+    let zk_key_db = if let Some(zk_key_datastore) = zk_key_datastore.as_ref() {
+        let zk_key_sled_cache_capacity =
+            sled_cache_capacity_bytes("zk_key_sled_cache_mb", settings.zk_key_sled_cache_mb)?;
+        info!(target: "taud", "Opening RLN key datastore with {} MiB sled cache", settings.zk_key_sled_cache_mb);
+        Some(
+            match sled::Config::new()
+                .path(zk_key_datastore.clone())
+                .cache_capacity(zk_key_sled_cache_capacity)
+                .open()
+            {
+                Ok(v) => v,
+                Err(e) => {
+                    error!(target: "taud", "Failed to open RLN key datastore `{zk_key_datastore:?}`: {e}");
+                    return Err(e.into());
+                }
+            },
+        )
+    } else {
+        None
+    };
+
     let p2p_settings: darkfi::net::Settings =
         (env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), settings.net.clone()).try_into()?;
     let comms_timeout = p2p_settings.outbound_connect_timeout_max();
@@ -568,20 +744,36 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
         initial_genesis: TAUD_INITIAL_GENESIS,
         hours_rotation: TAUD_HOURS_ROTATION,
         genesis_contents: TAUD_GENESIS_CONTENTS.to_vec(),
-        rln_enabled: false,
-        pregenerated_identity_commitments: Vec::new(),
+        rln_enabled,
+        pregenerated_identity_commitments: if rln_enabled {
+            taud::genesis_commits::pregenerated_identity_commitments()
+        } else {
+            Vec::new()
+        },
         max_dags: Some(TAUD_MAX_DAGS),
     };
-    let event_graph = match EventGraph::new(
-        p2p.clone(),
-        sled_db.clone(),
-        replay_datastore.clone(),
-        replay_mode,
-        eg_config,
-        executor.clone(),
-    )
-    .await
-    {
+    let event_graph = match if let Some(zk_key_db) = zk_key_db.clone() {
+        EventGraph::new_with_zk_key_db(
+            p2p.clone(),
+            sled_db.clone(),
+            zk_key_db,
+            replay_datastore.clone(),
+            replay_mode,
+            eg_config,
+            executor.clone(),
+        )
+        .await
+    } else {
+        EventGraph::new(
+            p2p.clone(),
+            sled_db.clone(),
+            replay_datastore.clone(),
+            replay_mode,
+            eg_config,
+            executor.clone(),
+        )
+        .await
+    } {
         Ok(v) => v,
         Err(e) => {
             error!("Event graph failed to start: {e}");
@@ -589,6 +781,20 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
         }
     };
 
+    // Set the active RLN account if any. When RLN is disabled, avoid
+    // loading account state that cannot affect outbound messages.
+    let rln_identity: Arc<smol::lock::RwLock<Option<RlnIdentity>>> =
+        Arc::new(smol::lock::RwLock::new(if event_graph.rln_enabled() {
+            let rln_identity = load_default_rln_identity(&sled_db).await?;
+            if rln_identity.is_some() {
+                info!(target: "taud", "Default RLN account set");
+            }
+            rln_identity
+        } else {
+            info!(target: "taud", "RLN disabled; skipping default RLN account load");
+            None
+        }));
+
     info!(target: "taud", "Registering EventGraph P2P protocol");
     let event_graph_ = Arc::clone(&event_graph);
     let registry = p2p.protocol_registry();
@@ -609,9 +815,21 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
             info!(target: "taud", "Got peer connection");
             // We'll attempt to sync for ever
             if !settings.skip_dag_sync {
+                info!(target: "taud", "Syncing static DAG");
+                match event_graph.static_sync().await {
+                    Ok(()) => info!(target: "taud", "Static synced successfully"),
+                    Err(e) => {
+                        error!(target: "taud", "Failed syncing static graph: {e}");
+                        sleep(comms_timeout).await;
+                        continue
+                    }
+                }
                 info!(target: "taud", "Syncing event DAG");
                 match event_graph.sync_selected(1).await {
-                    Ok(()) => break,
+                    Ok(()) => {
+                        info!(target: "taud", "Event DAG synced successfully!");
+                        break
+                    }
                     Err(e) => {
                         // TODO: Maybe at this point we should prune or something?
                         // TODO: Or maybe just tell the user to delete the DAG from FS.
@@ -667,6 +885,7 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
             settings.clone(),
             p2p.clone(),
             seen.clone(),
+            rln_identity.clone(),
         ),
         |res| async {
             match res {
@@ -744,6 +963,8 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
         event_graph.clone(),
         json_sub,
         deg_sub,
+        sled_db.clone(),
+        rln_identity.clone(),
     ));
     let rpc_task = StoppableTask::new();
     rpc_task.clone().start(
@@ -778,6 +999,87 @@ async fn realmain(settings: Args, executor: Arc<smol::Executor<'static>>) -> Res
     let flushed_bytes = sled_db.flush_async().await?;
     info!(target: "taud", "Flushed {flushed_bytes} bytes");
 
+    if let Some(zk_key_db) = zk_key_db {
+        info!(target: "taud", "Flushing RLN key sled database...");
+        let flushed_key_bytes = zk_key_db.flush_async().await?;
+        info!(target: "taud", "Flushed {flushed_key_bytes} RLN key bytes");
+    }
+
     info!(target: "taud", "Shut down successfully");
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use std::path::Path;
+
+    use structopt::StructOpt;
+
+    use darkfi::util::time::Timestamp;
+    use taud::task_info::TaskInfo;
+
+    use super::*;
+
+    const TEST_DATA_PATH: &str = "/tmp/test_tau_ws_claim";
+
+    /// Two workspaces that share the same read/write key, as is common in
+    /// localnet testing where one workspace block is duplicated.
+    fn shared_key_workspaces() -> BTreeMap<String, Workspace> {
+        let read_key = SecretKey::generate(&mut OsRng);
+        let chacha = ChaChaBox::new(&read_key.public_key(), &read_key);
+
+        let write_key = darkfi_sdk::crypto::SecretKey::random(&mut OsRng);
+        let write_pubkey = PublicKey::from_secret(write_key);
+
+        let mut map = BTreeMap::new();
+        map.insert(
+            "darkfi-dev".to_string(),
+            Workspace {
+                read_key: ChaChaBox::new(&read_key.public_key(), &read_key),
+                write_key: Some(write_key),
+                write_pubkey,
+            },
+        );
+        map.insert(
+            "test".to_string(),
+            Workspace { read_key: chacha, write_key: Some(write_key), write_pubkey },
+        );
+        map
+    }
+
+    #[test]
+    fn shared_keys_task_is_claimed_by_first_workspace() -> TaudResult<()> {
+        remove_dir_all(TEST_DATA_PATH).ok();
+        create_dir_all(TEST_DATA_PATH).unwrap();
+        create_dir_all(Path::new(TEST_DATA_PATH).join("task")).unwrap();
+        create_dir_all(Path::new(TEST_DATA_PATH).join("month")).unwrap();
+
+        let workspaces = shared_key_workspaces();
+
+        let mut args = Args::from_iter_safe(vec!["taud".to_string()]).unwrap();
+        args.datastore = TEST_DATA_PATH.to_string();
+
+        let task = TaskInfo::new(
+            "darkfi-dev".to_string(),
+            "test_title",
+            "test_desc",
+            "NICK",
+            None,
+            None,
+            Timestamp::current_time(),
+            None,
+        )?;
+
+        let enc = encrypt_sign_task(&task, workspaces.get("darkfi-dev").unwrap())?;
+
+        smol::block_on(async { on_receive_task(&enc, &workspaces, &args).await })?;
+
+        // Even though both workspaces can decrypt the task, it must be
+        // claimed exactly once and keep the originating workspace label,
+        // not be overwritten by the later `test` workspace.
+        let loaded = TaskInfo::load(&task.ref_id, Path::new(TEST_DATA_PATH))?;
+        assert_eq!(loaded.workspace, "darkfi-dev");
+
+        Ok(())
+    }
+}

+ 365 - 0
bin/tau/taud/src/rln.rs

@@ -0,0 +1,365 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use darkfi::{
+    event_graph::{
+        rln::{
+            epoch_of, hash_event, Blob, RegistrationAttestation, RlnProver, SignalProvingRequest,
+        },
+        Event, EventGraphPtr,
+    },
+    util::memory::log_memory,
+    zk::halo2::Field,
+    Result,
+};
+use darkfi_sdk::{crypto::poseidon_hash, pasta::pallas};
+use darkfi_serial::{
+    async_trait, deserialize_async, serialize_async, SerialDecodable, SerialEncodable,
+};
+use rand::{CryptoRng, RngCore};
+use sled_overlay::sled;
+use tracing::{info, warn};
+
+/// Domain-separation tags for credential generation.
+pub const RLN_TRAPDOOR_DERIVATION_PATH: pallas::Base = pallas::Base::from_raw([4311, 0, 0, 0]);
+pub const RLN_NULLIFIER_DERIVATION_PATH: pallas::Base = pallas::Base::from_raw([4312, 0, 0, 0]);
+
+/// Name of the sled tree that mirrors the currently-active identity.
+/// Read on startup to populate the active RLN identity.
+pub const ACCOUNTS_DEFAULT_TREE: &str = "tau_account_default";
+
+/// Prefix of the sled tree under which each registered account lives.
+pub const ACCOUNTS_DB_PREFIX: &str = "tau_account_";
+
+/// Key inside each account tree holding the serialized [`RlnIdentity`].
+pub const ACCOUNTS_KEY_RLN_IDENTITY: &[u8] = b"rln_identity";
+
+/// A user-side RLN identity: long-lived secrets plus a per-epoch
+/// send counter.
+///
+/// The struct is `Copy` so it can be cheaply duplicated after a
+/// message slot has been reserved. The canonical mutable copy lives
+/// behind the `rln_identity` lock and outbound sends must reserve a
+/// slot through [`reserve_rln_message_id_in_store`], which persists
+/// `message_id` and `last_epoch` before proof creation. Persisting
+/// first means a crash can burn a slot, but cannot roll the counter
+/// back and self-slash the identity on restart.
+#[derive(Copy, Clone, SerialEncodable, SerialDecodable)]
+pub struct RlnIdentity {
+    pub nullifier: pallas::Base,
+    pub trapdoor: pallas::Base,
+    pub user_message_limit: u64,
+    /// Monotonic counter within the current epoch. Reset whenever
+    /// `last_epoch` advances.
+    pub message_id: u64,
+    /// Last epoch we observed. Bookkeeping for the counter reset
+    /// above; not used cryptographically.
+    pub last_epoch: u64,
+}
+
+impl RlnIdentity {
+    /// Generate a fresh identity.
+    pub fn new(mut rng: impl CryptoRng + RngCore) -> Self {
+        Self {
+            nullifier: poseidon_hash([
+                RLN_NULLIFIER_DERIVATION_PATH,
+                pallas::Base::random(&mut rng),
+            ]),
+            trapdoor: poseidon_hash([RLN_TRAPDOOR_DERIVATION_PATH, pallas::Base::random(&mut rng)]),
+            // Default to the pregenerated-identity budget. Fresh
+            // identities are useful for generating future genesis bundles,
+            // but the live network currently admits only commitments already
+            // present in the configured pregenerated set.
+            user_message_limit: RegistrationAttestation::SPECIAL_TIER_LIMIT,
+            message_id: 0,
+            last_epoch: 0,
+        }
+    }
+
+    /// `identity_secret = poseidon(nullifier, trapdoor)`. Internal
+    /// to the RLN-V2 algebra.
+    pub fn identity_secret(&self) -> pallas::Base {
+        poseidon_hash([self.nullifier, self.trapdoor])
+    }
+
+    /// `identity_secret_hash = poseidon(identity_secret, user_message_limit)`.
+    /// This is the value recovered by SSS during a slash, NOT the
+    /// raw secret tuple.
+    pub fn identity_secret_hash(&self) -> pallas::Base {
+        poseidon_hash([self.identity_secret(), pallas::Base::from(self.user_message_limit)])
+    }
+
+    /// `commitment = poseidon(identity_secret_hash)`. The leaf in
+    /// the SMT.
+    pub fn commitment(&self) -> pallas::Base {
+        poseidon_hash([self.identity_secret_hash()])
+    }
+
+    /// Advance the per-epoch counter for a signal at the given
+    /// timestamp. Returns `None` if the user has already burnt
+    /// their `user_message_limit` for this epoch (in which case the
+    /// caller should drop the message rather than emit a signal
+    /// that would slash the identity).
+    ///
+    /// On epoch rollover the counter resets and a fresh slot 0 is
+    /// returned.
+    pub fn next_message_id(&mut self, now_millis: u64) -> Option<u64> {
+        let epoch = epoch_of(now_millis);
+        if epoch != self.last_epoch {
+            self.last_epoch = epoch;
+            self.message_id = 0;
+        }
+        if self.message_id >= self.user_message_limit {
+            return None
+        }
+        let m = self.message_id;
+        self.message_id += 1;
+        Some(m)
+    }
+
+    /// Build a signal [`Blob`] for the given event using `message_id`.
+    ///
+    /// The merkle root and inclusion path come from the
+    /// EventGraph's canonical [`IdentityState`] via
+    /// [`EventGraph::rln_membership_path`] - the verifier and the
+    /// prover therefore agree on the root by construction, with no
+    /// risk of the client and the EG drifting out of sync.
+    ///
+    /// [`IdentityState`]: darkfi::event_graph::rln::IdentityState
+    /// [`EventGraph::rln_membership_path`]: darkfi::event_graph::EventGraph::rln_membership_path
+    pub async fn create_signal(
+        &self,
+        event: &Event,
+        message_id: u64,
+        eg: &EventGraphPtr,
+    ) -> Result<Blob> {
+        // RLN external nullifier: ties the message to (epoch, app).
+        // Cross-app isolation comes from `app_id` differing per
+        // EventGraph deployment (derived from
+        // config.genesis_contents).
+        let app_id = eg.rln_app_id().as_field();
+        let epoch = pallas::Base::from(epoch_of(event.header.timestamp));
+        let mid = pallas::Base::from(message_id);
+        let ext_null = poseidon_hash([epoch, app_id]);
+
+        // Rate-limit polynomial: y = a_0 + x * a_1.
+        // a_0 is identity_secret_hash; a_1 is bound to (a_0,
+        // ext_null, message_id). Two distinct (x, y) for the same
+        // internal nullifier let SSS recover a_0, which is what
+        // enables slashing.
+        let a_0 = self.identity_secret_hash();
+        let a_1 = poseidon_hash([a_0, ext_null, mid]);
+        let internal_nullifier = poseidon_hash([a_1]);
+        let x = hash_event(event);
+        let y = a_0 + x * a_1;
+
+        // Canonical membership path via the EG.
+        let (root, path) = eg.rln_membership_path(&self.commitment()).await?;
+
+        let request = SignalProvingRequest {
+            nullifier: self.nullifier,
+            trapdoor: self.trapdoor,
+            message_id: mid,
+            merkle_path: path.path,
+            x,
+            user_message_limit: self.user_message_limit,
+            app_id,
+            epoch,
+            merkle_root: root,
+            external_nullifier: ext_null,
+            y,
+            internal_nullifier,
+        };
+
+        log_memory("before local signal proving");
+        info!(
+            target: "taud::rln",
+            "[RLN] Creating signal proof for event {}",
+            event.id(),
+        );
+        let proof = eg.rln_zk_keys()?.prove_signal(request).await?.proof;
+        log_memory("after local signal proving");
+
+        Ok(Blob {
+            proof,
+            y,
+            internal_nullifier,
+            user_msg_limit: self.user_message_limit,
+            merkle_root: root,
+        })
+    }
+}
+
+/// Result of attempting to reserve the next RLN message slot.
+pub enum RlnMessageReservation {
+    /// No active RLN identity is configured.
+    MissingIdentity,
+    /// The active identity has already used its epoch budget.
+    BudgetExhausted,
+    /// A message slot was persisted and can be used to build a proof.
+    Reserved { identity: RlnIdentity, message_id: u64 },
+}
+
+/// Persist the active RLN counter to the default mirror and matching account tree.
+pub async fn persist_rln_identity_counter(
+    sled_db: &sled::Db,
+    identity: &RlnIdentity,
+) -> Result<()> {
+    let encoded = serialize_async(identity).await;
+    let active_commitment = identity.commitment();
+    let mut updated_account = false;
+
+    for raw in sled_db.tree_names() {
+        let bytes: &[u8] = raw.as_ref();
+        let Ok(name) = std::str::from_utf8(bytes) else { continue };
+        let Some(account_name) = name.strip_prefix(ACCOUNTS_DB_PREFIX) else { continue };
+        if account_name == "default" || account_name.is_empty() {
+            continue
+        }
+
+        let tree = sled_db.open_tree(name)?;
+        let Some(blob) = tree.get(ACCOUNTS_KEY_RLN_IDENTITY)? else { continue };
+        let Ok(stored): std::result::Result<RlnIdentity, _> = deserialize_async(&blob).await else {
+            continue
+        };
+        if stored.commitment() == active_commitment {
+            tree.insert(ACCOUNTS_KEY_RLN_IDENTITY, encoded.clone())?;
+            updated_account = true;
+        }
+    }
+
+    if !updated_account {
+        warn!(
+            target: "taud::rln",
+            "active RLN identity has no matching account tree; persisting default mirror only",
+        );
+    }
+
+    let default_db = sled_db.open_tree(ACCOUNTS_DEFAULT_TREE)?;
+    default_db.insert(ACCOUNTS_KEY_RLN_IDENTITY, encoded)?;
+    sled_db.flush_async().await?;
+    Ok(())
+}
+
+/// Reserve the next RLN message ID and persist it before proof creation.
+pub async fn reserve_rln_message_id_in_store(
+    sled_db: &sled::Db,
+    active: &mut Option<RlnIdentity>,
+    now_millis: u64,
+) -> Result<RlnMessageReservation> {
+    let Some(current) = active else { return Ok(RlnMessageReservation::MissingIdentity) };
+
+    let mut updated = *current;
+    let Some(message_id) = updated.next_message_id(now_millis) else {
+        return Ok(RlnMessageReservation::BudgetExhausted)
+    };
+
+    persist_rln_identity_counter(sled_db, &updated).await?;
+    *current = updated;
+
+    Ok(RlnMessageReservation::Reserved { identity: updated, message_id })
+}
+
+/// Load the active (default-mirror) RLN identity, if any.
+pub async fn load_default_rln_identity(sled_db: &sled::Db) -> Result<Option<RlnIdentity>> {
+    let default_db = sled_db.open_tree(ACCOUNTS_DEFAULT_TREE)?;
+    let Some(blob) = default_db.get(ACCOUNTS_KEY_RLN_IDENTITY)? else {
+        if default_db.is_empty() {
+            return Ok(None)
+        }
+
+        return Err(darkfi::Error::ParseFailed("Default RLN account is missing identity record"))
+    };
+
+    let identity: RlnIdentity = deserialize_async(&blob)
+        .await
+        .map_err(|_| darkfi::Error::ParseFailed("Default RLN account identity is corrupted"))?;
+
+    Ok(Some(identity))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use darkfi_sdk::pasta::pallas;
+    use darkfi_serial::deserialize_async;
+
+    #[test]
+    fn load_default_rln_identity_returns_none_for_empty_tree() {
+        smol::block_on(async {
+            let sled_db = sled::Config::new().temporary(true).open().unwrap();
+
+            let identity = load_default_rln_identity(&sled_db).await.unwrap();
+
+            assert!(identity.is_none());
+        })
+    }
+
+    #[test]
+    fn rln_message_reservation_persists_default_and_account_counters() {
+        smol::block_on(async {
+            let sled_db = sled::Config::new().temporary(true).open().unwrap();
+            let account = sled_db.open_tree(format!("{ACCOUNTS_DB_PREFIX}alice")).unwrap();
+            let default = sled_db.open_tree(ACCOUNTS_DEFAULT_TREE).unwrap();
+            let identity = RlnIdentity {
+                nullifier: pallas::Base::from(0xabc_u64),
+                trapdoor: pallas::Base::from(0xdef_u64),
+                user_message_limit: 2,
+                message_id: 0,
+                last_epoch: 0,
+            };
+            let encoded = serialize_async(&identity).await;
+            account.insert(ACCOUNTS_KEY_RLN_IDENTITY, encoded.clone()).unwrap();
+            default.insert(ACCOUNTS_KEY_RLN_IDENTITY, encoded).unwrap();
+
+            let now = 1_704_067_800_000;
+            let mut active = Some(identity);
+            let reservation =
+                reserve_rln_message_id_in_store(&sled_db, &mut active, now).await.unwrap();
+            let RlnMessageReservation::Reserved { identity: reserved, message_id } = reservation
+            else {
+                panic!("expected reservation")
+            };
+            assert_eq!(message_id, 0);
+            assert_eq!(reserved.message_id, 1);
+            assert_eq!(reserved.last_epoch, epoch_of(now));
+
+            let stored_default: RlnIdentity =
+                deserialize_async(&default.get(ACCOUNTS_KEY_RLN_IDENTITY).unwrap().unwrap())
+                    .await
+                    .unwrap();
+            let stored_account: RlnIdentity =
+                deserialize_async(&account.get(ACCOUNTS_KEY_RLN_IDENTITY).unwrap().unwrap())
+                    .await
+                    .unwrap();
+            assert_eq!(stored_default.message_id, 1);
+            assert_eq!(stored_account.message_id, 1);
+
+            let reservation =
+                reserve_rln_message_id_in_store(&sled_db, &mut active, now).await.unwrap();
+            let RlnMessageReservation::Reserved { message_id, .. } = reservation else {
+                panic!("expected second reservation")
+            };
+            assert_eq!(message_id, 1);
+
+            let exhausted =
+                reserve_rln_message_id_in_store(&sled_db, &mut active, now).await.unwrap();
+            assert!(matches!(exhausted, RlnMessageReservation::BudgetExhausted));
+        })
+    }
+}

+ 20 - 0
bin/tau/taud/src/settings.rs

@@ -41,6 +41,26 @@ pub struct Args {
     /// Replay logs (DB) path
     pub replay_datastore: String,
 
+    #[structopt(long)]
+    /// Enable RLN proof generation and verification
+    pub rln_enabled: Option<bool>,
+
+    #[structopt(long, default_value = "~/.local/share/darkfi/taud/zk_keys")]
+    /// Datastore path for RLN proving and verifying keys
+    pub zk_key_datastore: String,
+
+    #[structopt(long, default_value = "16")]
+    /// Sled cache capacity for the RLN key datastore, in MiB
+    pub zk_key_sled_cache_mb: u64,
+
+    #[structopt(long)]
+    /// Generate a new RLN identity
+    pub gen_rln_identity: bool,
+
+    #[structopt(long)]
+    /// Generate N genesis RLN identities
+    pub gen_genesis_rln_identities: Option<u64>,
+
     #[structopt(long)]
     /// Flag to store Sled DB instructions
     pub replay_mode: bool,

+ 10 - 0
bin/tau/taud/taud_config.toml

@@ -7,6 +7,16 @@
 ## Sets DB logs replay datastore path
 #replay_datastore = "~/.local/share/darkfi/replayed_taud_db"
 
+## Enable RLN proof generation and verification. Disabled by default to skip
+## RLN key loading, identity SMT state, proof generation, and proof verification.
+#rln_enabled = false
+
+## Datastore path for RLN proving and verifying keys
+#zk_key_datastore = "~/.local/share/darkfi/taud/zk_keys"
+
+## Sled cache capacity for the RLN key datastore, in MiB
+#zk_key_sled_cache_mb = 16
+
 ## Run in replay mode to store Sled DB instructions
 ## (for eventgraph debugging tool)
 #replay_mode = false