diff --git a/apps/chat2mix/chat2mix.nim b/apps/chat2mix/chat2mix.nim index d88325568..a70010da8 100644 --- a/apps/chat2mix/chat2mix.nim +++ b/apps/chat2mix/chat2mix.nim @@ -557,7 +557,8 @@ proc processInput(rfd: AsyncFD, rng: ref HmacDrbgContext) {.async.} = ( await node.mountMix( - conf.clusterId, mixPrivKey, conf.mixnodes, some(conf.rlnUserMessageLimit) + conf.clusterId, mixPrivKey, conf.mixnodes, some(conf.rlnUserMessageLimit), + conf.rlnServiceUrl, ) ).isOkOr: error "failed to mount waku mix protocol: ", error = $error diff --git a/apps/chat2mix/config_chat2mix.nim b/apps/chat2mix/config_chat2mix.nim index e17827533..ea9e997b4 100644 --- a/apps/chat2mix/config_chat2mix.nim +++ b/apps/chat2mix/config_chat2mix.nim @@ -244,6 +244,13 @@ type name: "rln-user-message-limit" .}: int + rlnServiceUrl* {. + desc: + "URL of the external RLN Merkle proof service (required for group manager)", + defaultValue: "", + name: "rln-service-url" + .}: string + proc parseCmdArg*(T: type MixNodePubInfo, p: string): T = let elements = p.split(":") if elements.len != 2: diff --git a/simulations/mixnet/README.md b/simulations/mixnet/README.md index 99b0ba50b..2e0e05053 100644 --- a/simulations/mixnet/README.md +++ b/simulations/mixnet/README.md @@ -11,6 +11,7 @@ The simulation includes: 1. A 5-node mixnet where `run_mix_node.sh` is the bootstrap node for the other 4 nodes 2. Two chat app instances that publish messages using lightpush protocol over the mixnet +3. A local LEZ sequencer, RLN registration program, and a JSON-RPC service for interacting with the program ### Available Scripts @@ -35,9 +36,39 @@ source env.sh make wakunode2 chat2mix ``` +## RLN Program and Service + +Requires Docker. + +Install Rust and the RISC Zero toolchain: +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +curl -L https://risczero.com/install | bash +rzup install +``` + +Clone the RLN program repo and build the guest programs: +```bash +git clone -b feat/rln-service git@github.com:logos-co/logos-lez-rln.git +cd logos-lez-rln +cargo risczero build --manifest-path methods/guest/Cargo.toml +``` + +Start the local sequencer (runs in foreground): +```bash +./dev.sh +``` + +In another terminal, run setup and start the service: +```bash +source dev/env.sh && cargo run --bin run_setup && cargo run --bin rln_service +``` + +The service listens on http://127.0.0.1:3001. + ## RLN Spam Protection Setup -Generate RLN credentials and the shared Merkle tree for all nodes: +Generate RLN credentials and register them in the on-chain Merkle tree for all nodes: ```bash cd simulations/mixnet @@ -48,10 +79,10 @@ This script will: 1. Build and run the `setup_credentials` tool 2. Generate RLN credentials for all nodes (5 mix nodes + 2 chat clients) -3. Create `rln_tree.db` - the shared Merkle tree with all members -4. Create keystore files (`rln_keystore_{peerId}.json`) for each node +3. Create keystore files (`rln_keystore_{peerId}.json`) for each node +4. Call the RLN service to register each identity -**Important:** All scripts must be run from this directory (`simulations/mixnet/`) so they can access their credentials and tree file. +**Important:** All scripts must be run from this directory (`simulations/mixnet/`) so they can access their credential files. To regenerate credentials (e.g., after adding new nodes), run `./build_setup.sh` again - it will clean up old files first. @@ -77,7 +108,7 @@ Verify RLN spam protection initialized correctly by checking for these logs: ```log INF Initializing MixRlnSpamProtection INF MixRlnSpamProtection initialized, waiting for sync -DBG Tree loaded from file +INF Group manager started INF MixRlnSpamProtection started ``` diff --git a/simulations/mixnet/build_setup.sh b/simulations/mixnet/build_setup.sh index 8265d88c1..6d77a746c 100755 --- a/simulations/mixnet/build_setup.sh +++ b/simulations/mixnet/build_setup.sh @@ -4,8 +4,11 @@ MIXNET_DIR=$(pwd) cd ../.. ROOT_DIR=$(pwd) -# Clean up old files first -rm -f "$MIXNET_DIR/rln_tree.db" "$MIXNET_DIR"/rln_keystore_*.json +# Source env.sh to get the correct nim with vendor paths +source "$ROOT_DIR/env.sh" + +# Clean up old keystore files +rm -f "$MIXNET_DIR"/rln_keystore_*.json echo "Building and running credentials setup..." # Compile to temp location, then run from mixnet directory @@ -17,16 +20,17 @@ nim c -d:release --mm:refc \ # Run from mixnet directory so files are created there cd "$MIXNET_DIR" /tmp/setup_credentials_$$ +RESULT=$? # Clean up temp binary rm -f /tmp/setup_credentials_$$ # Verify output -if [ -f "rln_tree.db" ]; then +if [ $RESULT -eq 0 ]; then echo "" - echo "Tree file ready at: $(pwd)/rln_tree.db" - ls -la rln_keystore_*.json 2>/dev/null | wc -l | xargs -I {} echo "Generated {} keystore files" + KEYSTORE_COUNT=$(ls -1 rln_keystore_*.json 2>/dev/null | wc -l | tr -d ' ') + echo "Generated $KEYSTORE_COUNT keystore files" else - echo "Setup failed - rln_tree.db not found" + echo "Setup failed" exit 1 fi diff --git a/simulations/mixnet/config.toml b/simulations/mixnet/config.toml index 5cd1aa936..f72be2315 100644 --- a/simulations/mixnet/config.toml +++ b/simulations/mixnet/config.toml @@ -1,6 +1,7 @@ log-level = "TRACE" relay = true mix = true +rln-service-url = "http://127.0.0.1:3001" filter = true store = true lightpush = true diff --git a/simulations/mixnet/config1.toml b/simulations/mixnet/config1.toml index 73cccb8c6..67f721d38 100644 --- a/simulations/mixnet/config1.toml +++ b/simulations/mixnet/config1.toml @@ -1,6 +1,7 @@ log-level = "TRACE" relay = true mix = true +rln-service-url = "http://127.0.0.1:3001" filter = true store = false lightpush = true diff --git a/simulations/mixnet/config2.toml b/simulations/mixnet/config2.toml index 3acd2bf8a..1b3db3f42 100644 --- a/simulations/mixnet/config2.toml +++ b/simulations/mixnet/config2.toml @@ -1,6 +1,7 @@ log-level = "TRACE" relay = true mix = true +rln-service-url = "http://127.0.0.1:3001" filter = true store = false lightpush = true diff --git a/simulations/mixnet/config3.toml b/simulations/mixnet/config3.toml index bd8e7c4e9..aa6816be4 100644 --- a/simulations/mixnet/config3.toml +++ b/simulations/mixnet/config3.toml @@ -1,6 +1,7 @@ log-level = "TRACE" relay = true mix = true +rln-service-url = "http://127.0.0.1:3001" filter = true store = false lightpush = true diff --git a/simulations/mixnet/config4.toml b/simulations/mixnet/config4.toml index f174250d5..6e1e6fa99 100644 --- a/simulations/mixnet/config4.toml +++ b/simulations/mixnet/config4.toml @@ -1,6 +1,7 @@ log-level = "TRACE" relay = true mix = true +rln-service-url = "http://127.0.0.1:3001" filter = true store = false lightpush = true diff --git a/simulations/mixnet/run_chat_mix.sh b/simulations/mixnet/run_chat_mix.sh index ef0575375..92cfa88c4 100755 --- a/simulations/mixnet/run_chat_mix.sh +++ b/simulations/mixnet/run_chat_mix.sh @@ -1,2 +1,2 @@ -../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE --nodekey="cb6fe589db0e5d5b48f7e82d33093e4d9d35456f4aaffc2322c473a173b2ac49" --kad-bootstrap-node="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --fleet="none" +../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE --nodekey="cb6fe589db0e5d5b48f7e82d33093e4d9d35456f4aaffc2322c473a173b2ac49" --kad-bootstrap-node="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --fleet="none" --rln-service-url=http://127.0.0.1:3001 #--mixnode="/ip4/127.0.0.1/tcp/60002/p2p/16Uiu2HAmLtKaFaSWDohToWhWUZFLtqzYZGPFuXwKrojFVF6az5UF:9231e86da6432502900a84f867004ce78632ab52cd8e30b1ec322cd795710c2a" --mixnode="/ip4/127.0.0.1/tcp/60003/p2p/16Uiu2HAmTEDHwAziWUSz6ZE23h5vxG2o4Nn7GazhMor4bVuMXTrA:275cd6889e1f29ca48e5b9edb800d1a94f49f13d393a0ecf1a07af753506de6c" --mixnode="/ip4/127.0.0.1/tcp/60004/p2p/16Uiu2HAmPwRKZajXtfb1Qsv45VVfRZgK3ENdfmnqzSrVm3BczF6f:e0ed594a8d506681be075e8e23723478388fb182477f7a469309a25e7076fc18" --mixnode="/ip4/127.0.0.1/tcp/60005/p2p/16Uiu2HAmRhxmCHBYdXt1RibXrjAUNJbduAhzaTHwFCZT4qWnqZAu:8fd7a1a7c19b403d231452a9b1ea40eb1cc76f455d918ef8980e7685f9eeeb1f" diff --git a/simulations/mixnet/run_chat_mix1.sh b/simulations/mixnet/run_chat_mix1.sh index 5961fce45..65e5d4dad 100755 --- a/simulations/mixnet/run_chat_mix1.sh +++ b/simulations/mixnet/run_chat_mix1.sh @@ -1 +1 @@ -../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE --nodekey="35eace7ccb246f20c487e05015ca77273d8ecaed0ed683de3d39bf4f69336feb" --mixnode="/ip4/127.0.0.1/tcp/60002/p2p/16Uiu2HAmLtKaFaSWDohToWhWUZFLtqzYZGPFuXwKrojFVF6az5UF:9231e86da6432502900a84f867004ce78632ab52cd8e30b1ec322cd795710c2a" --mixnode="/ip4/127.0.0.1/tcp/60003/p2p/16Uiu2HAmTEDHwAziWUSz6ZE23h5vxG2o4Nn7GazhMor4bVuMXTrA:275cd6889e1f29ca48e5b9edb800d1a94f49f13d393a0ecf1a07af753506de6c" --mixnode="/ip4/127.0.0.1/tcp/60004/p2p/16Uiu2HAmPwRKZajXtfb1Qsv45VVfRZgK3ENdfmnqzSrVm3BczF6f:e0ed594a8d506681be075e8e23723478388fb182477f7a469309a25e7076fc18" --mixnode="/ip4/127.0.0.1/tcp/60005/p2p/16Uiu2HAmRhxmCHBYdXt1RibXrjAUNJbduAhzaTHwFCZT4qWnqZAu:8fd7a1a7c19b403d231452a9b1ea40eb1cc76f455d918ef8980e7685f9eeeb1f" --mixnode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o:9d09ce624f76e8f606265edb9cca2b7de9b41772a6d784bddaf92ffa8fba7d2c" --fleet="none" +../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE --nodekey="35eace7ccb246f20c487e05015ca77273d8ecaed0ed683de3d39bf4f69336feb" --mixnode="/ip4/127.0.0.1/tcp/60002/p2p/16Uiu2HAmLtKaFaSWDohToWhWUZFLtqzYZGPFuXwKrojFVF6az5UF:9231e86da6432502900a84f867004ce78632ab52cd8e30b1ec322cd795710c2a" --mixnode="/ip4/127.0.0.1/tcp/60003/p2p/16Uiu2HAmTEDHwAziWUSz6ZE23h5vxG2o4Nn7GazhMor4bVuMXTrA:275cd6889e1f29ca48e5b9edb800d1a94f49f13d393a0ecf1a07af753506de6c" --mixnode="/ip4/127.0.0.1/tcp/60004/p2p/16Uiu2HAmPwRKZajXtfb1Qsv45VVfRZgK3ENdfmnqzSrVm3BczF6f:e0ed594a8d506681be075e8e23723478388fb182477f7a469309a25e7076fc18" --mixnode="/ip4/127.0.0.1/tcp/60005/p2p/16Uiu2HAmRhxmCHBYdXt1RibXrjAUNJbduAhzaTHwFCZT4qWnqZAu:8fd7a1a7c19b403d231452a9b1ea40eb1cc76f455d918ef8980e7685f9eeeb1f" --mixnode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o:9d09ce624f76e8f606265edb9cca2b7de9b41772a6d784bddaf92ffa8fba7d2c" --fleet="none" --rln-service-url=http://127.0.0.1:3001 diff --git a/simulations/mixnet/setup_credentials b/simulations/mixnet/setup_credentials new file mode 100755 index 000000000..e95e3838a Binary files /dev/null and b/simulations/mixnet/setup_credentials differ diff --git a/simulations/mixnet/setup_credentials.nim b/simulations/mixnet/setup_credentials.nim index 77c796354..609b678d7 100644 --- a/simulations/mixnet/setup_credentials.nim +++ b/simulations/mixnet/setup_credentials.nim @@ -1,27 +1,26 @@ {.push raises: [].} -## Setup script to generate RLN credentials and shared Merkle tree for mix nodes. +## Setup script to generate RLN credentials and register them with the external service. ## ## This script: ## 1. Generates credentials for each node (identified by peer ID) -## 2. Registers all credentials in a shared Merkle tree -## 3. Saves the tree to rln_tree.db -## 4. Saves individual keystores named by peer ID +## 2. Registers all credentials with the external RLN service (in parallel) +## 3. Saves individual keystores named by peer ID, using the service's leaf index ## ## Usage: nim c -r setup_credentials.nim -import std/[os, strformat, options], chronicles, chronos, results +import std/[os, strformat, options, json, strutils], chronicles, chronos, results +import chronos/apps/http/[httpclient, httpcommon] import mix_rln_spam_protection/credentials, - mix_rln_spam_protection/group_manager, - mix_rln_spam_protection/rln_interface, mix_rln_spam_protection/types const KeystorePassword = "mix-rln-password" # Must match protocol.nim DefaultUserMessageLimit = 100'u64 # Network-wide default rate limit SpammerUserMessageLimit = 3'u64 # Lower limit for spammer testing + RlnServiceUrl = "http://127.0.0.1:3001" # Peer IDs derived from nodekeys in config files # config.toml: nodekey = "f98e3fba96c32e8d1967d460f1b79457380e1a895f7971cecc8528abe733781a" @@ -50,8 +49,47 @@ const # chat2mix client 2 ] -proc setupCredentialsAndTree() {.async.} = - ## Generate credentials for all nodes and create a shared tree +proc registerWithService( + session: HttpSessionRef, + address: HttpAddress, + idCommitment: IDCommitment, + rateLimit: uint64, + reqId: int, +): Future[int] {.async.} = + ## Register a credential with the external RLN service. + ## Returns the leaf index assigned by the service. + let commitmentHex = "0x" & idCommitment.toHex() + let body = $(%*{ + "jsonrpc": "2.0", + "method": "rln_register", + "params": [commitmentHex, rateLimit], + "id": reqId, + }) + + var req: HttpClientRequestRef = nil + var res: HttpClientResponseRef = nil + try: + req = HttpClientRequestRef.post( + session, address, + body = body.toOpenArrayByte(0, body.len - 1), + headers = @[("Content-Type", "application/json")], + ) + res = await req.send() + let resBytes = await res.getBodyBytes() + let parsed = parseJson(cast[string](resBytes)) + + if parsed.hasKey("error"): + raise newException(CatchableError, "Service error: " & $parsed["error"]) + + return parsed["result"]["leaf_index"].getInt() + finally: + if req != nil: + await req.closeWait() + if res != nil: + await res.closeWait() + +proc setupCredentials() {.async.} = + ## Generate credentials, register with external service in parallel, save keystores. echo "=== RLN Credentials Setup ===" echo "Generating credentials for ", NodeConfigs.len, " nodes...\n" @@ -71,69 +109,61 @@ proc setupCredentialsAndTree() {.async.} = echo "" - # Create a group manager directly to build the tree - let rlnInstance = newRLNInstance().valueOr: - echo "Failed to create RLN instance: ", error + # Register all credentials with the external RLN service in parallel + echo "Registering all credentials with external RLN service at ", RlnServiceUrl, " (parallel)..." + let session = HttpSessionRef.new() + let address = session.getAddress(RlnServiceUrl).valueOr: + echo "FATAL: Invalid RLN service URL: ", RlnServiceUrl quit(1) - let groupManager = newOffchainGroupManager(rlnInstance, "/mix/rln/membership/v1") - - # Initialize the group manager - let initRes = await groupManager.init() - if initRes.isErr: - echo "Failed to initialize group manager: ", initRes.error - quit(1) - - # Register all credentials in the tree with their specific rate limits - echo "Registering all credentials in the Merkle tree..." + var futures: seq[Future[int]] for i, entry in allCredentials: - let index = ( - await groupManager.registerWithLimit(entry.cred.idCommitment, entry.rateLimit) - ).valueOr: - echo "Failed to register credential for ", entry.peerId, ": ", error - quit(1) - echo " Registered ", - entry.peerId, " at index ", index, " (limit: ", entry.rateLimit, ")" + futures.add(registerWithService( + session, address, entry.cred.idCommitment, entry.rateLimit, i + 1 + )) - echo "" - - # Save the tree to disk - echo "Saving tree to rln_tree.db..." - let saveRes = groupManager.saveTreeToFile("rln_tree.db") - if saveRes.isErr: - echo "Failed to save tree: ", saveRes.error + var serviceIndices = newSeq[int](futures.len) + try: + await allFutures(futures) + for i, fut in futures: + if fut.failed: + raise fut.error + serviceIndices[i] = fut.read() + echo " Registered ", + allCredentials[i].peerId, " at service index ", serviceIndices[i], + " (limit: ", allCredentials[i].rateLimit, ")" + except CatchableError as e: + echo "FATAL: Failed to register with external service: ", e.msg + echo " The external RLN service must be running at ", RlnServiceUrl quit(1) - echo "Tree saved successfully!" + + await session.closeWait() echo "" - # Save each credential to a keystore file named by peer ID + # Save each credential to a keystore file using the service's leaf index echo "Saving keystores..." for i, entry in allCredentials: let keystorePath = &"rln_keystore_{entry.peerId}.json" + let membershipIndex = MembershipIndex(serviceIndices[i]) - # Save with membership index and rate limit let saveResult = saveKeystore( entry.cred, KeystorePassword, keystorePath, - some(MembershipIndex(i)), + some(membershipIndex), some(entry.rateLimit), ) if saveResult.isErr: echo "Failed to save keystore for ", entry.peerId, ": ", saveResult.error quit(1) - echo " Saved: ", keystorePath, " (limit: ", entry.rateLimit, ")" + echo " Saved: ", keystorePath, " (index: ", membershipIndex, ", limit: ", entry.rateLimit, ")" echo "" echo "=== Setup Complete ===" - echo " Tree file: rln_tree.db (", NodeConfigs.len, " members)" echo " Keystores: rln_keystore_{peerId}.json" echo " Password: ", KeystorePassword echo " Default rate limit: ", DefaultUserMessageLimit - echo " Spammer rate limit: ", SpammerUserMessageLimit - echo "" - echo "Note: All nodes must use the same rln_tree.db file." when isMainModule: - waitFor setupCredentialsAndTree() + waitFor setupCredentials() diff --git a/simulations/mixnet/test_onchain_gm b/simulations/mixnet/test_onchain_gm new file mode 100755 index 000000000..831c2d4ba Binary files /dev/null and b/simulations/mixnet/test_onchain_gm differ diff --git a/simulations/mixnet/test_onchain_gm.nim b/simulations/mixnet/test_onchain_gm.nim new file mode 100644 index 000000000..21b2e9d9f --- /dev/null +++ b/simulations/mixnet/test_onchain_gm.nim @@ -0,0 +1,149 @@ +## Integration test: GroupManager with external RLN service. +## +## Prerequisites: +## - RLN service running at http://127.0.0.1:3001 +## - Run setup_credentials first to generate keystores +## +## Usage: nim c -r --mm:refc test_onchain_gm.nim [http://127.0.0.1:3001] + +import std/[os, options] +import chronos, results, chronicles + +import + mix_rln_spam_protection/types, + mix_rln_spam_protection/constants, + mix_rln_spam_protection/rln_interface, + mix_rln_spam_protection/group_manager, + mix_rln_spam_protection/credentials + +# Import the service client from the waku layer +import ../../waku/waku_mix/rln_service_client + +const + DefaultUrl = "http://127.0.0.1:3001" + KeystorePassword = "mix-rln-password" + # Use the first node's peer ID for keystore lookup + TestPeerId = "16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" + +proc main() {.async.} = + let serviceUrl = if paramCount() >= 1: paramStr(1) else: DefaultUrl + echo "=== OnchainGroupManager Integration Test ===" + echo "Service URL: ", serviceUrl + + # 1. Create RLN instance + echo "\n1. Creating RLN instance..." + let rlnInstance = newRLNInstance().valueOr: + echo "FAIL: ", error + quit(1) + echo " OK" + + # 2. Create GroupManager + echo "\n2. Creating GroupManager..." + let gm = newGroupManager( + rlnInstance, pollIntervalSeconds = 2.0, userMessageLimit = 100 + ) + + # 3. Set service callbacks + echo "\n3. Setting service callbacks..." + gm.setFetchLatestRoots(makeFetchLatestRoots(serviceUrl)) + gm.setFetchMerkleProof(makeFetchMerkleProof(serviceUrl)) + echo " OK" + + # 4. Initialize + echo "\n4. Initializing group manager..." + let initRes = await gm.init() + if initRes.isErr: + echo "FAIL: ", initRes.error + quit(1) + echo " OK" + + # 5. Load credentials from keystore + echo "\n5. Loading credentials..." + let keystorePath = "rln_keystore_" & TestPeerId & ".json" + if not fileExists(keystorePath): + echo "FAIL: Keystore not found at ", keystorePath + echo " Run build_setup.sh first" + quit(1) + + let (cred, maybeIndex, maybeRateLimit, wasGenerated) = + loadOrGenerateCredentials(keystorePath, KeystorePassword).valueOr: + echo "FAIL: ", error + quit(1) + + echo " Loaded credential from keystore" + echo " idCommitment: ", cred.idCommitment.toHex()[0..15], "..." + if maybeIndex.isSome: + echo " membershipIndex: ", maybeIndex.get() + if maybeRateLimit.isSome: + echo " userMessageLimit: ", maybeRateLimit.get() + + # 6. Register credentials with the group manager + echo "\n6. Registering credentials..." + if maybeIndex.isSome: + gm.credentials = some(cred) + gm.membershipIndex = some(maybeIndex.get()) + echo " Set credentials with index ", maybeIndex.get() + else: + echo " No index in keystore, registering..." + let regRes = await gm.register(cred) + if regRes.isErr: + echo "FAIL: ", regRes.error + quit(1) + echo " Registered at index ", regRes.get() + + # 7. Start the group manager (begins polling) + echo "\n7. Starting group manager (polling)..." + let startRes = await gm.start() + if startRes.isErr: + echo "FAIL: ", startRes.error + quit(1) + echo " OK - polling started" + + # 8. Wait for cached proof to arrive + echo "\n8. Waiting for cached proof..." + var attempts = 0 + while attempts < 10: + await sleepAsync(chronos.seconds(1)) + attempts.inc() + # Try generating a proof to see if cache is populated + let testSignal = @[byte(1), 2, 3, 4] + let epoch = currentEpoch() + var rlnId: RlnIdentifier + let idStr = MixRlnIdentifier + let copyLen = min(idStr.len, HashByteSize) + if copyLen > 0: + copyMem(addr rlnId[0], unsafeAddr idStr[0], copyLen) + let proofRes = gm.generateProof(testSignal, epoch, rlnId) + if proofRes.isOk: + echo " Proof generated after ", attempts, " seconds!" + let proof = proofRes.get() + echo " merkleRoot: ", proof.merkleRoot.toHex()[0..15], "..." + echo " epoch: ", proof.epoch.epochToUint64() + echo " nullifier: ", proof.nullifier.toHex()[0..15], "..." + + # 9. Verify the proof + echo "\n9. Verifying proof..." + # Pass the proof's root as a valid root since our local tree is empty + let verifyRes = rlnInstance.verifyRlnProof( + proof, rlnId, testSignal, validRoots = @[proof.merkleRoot] + ) + if verifyRes.isOk and verifyRes.get(): + echo " PASS - proof verified successfully!" + else: + echo " FAIL - proof verification failed" + if verifyRes.isErr: + echo " Error: ", verifyRes.error + + # 10. Stop + echo "\n10. Stopping..." + await gm.stop() + echo " Stopped" + + echo "\n=== ALL TESTS PASSED ===" + return + + echo " FAIL: No cached proof after ", attempts, " seconds" + await gm.stop() + quit(1) + +waitFor main() diff --git a/simulations/mixnet/test_rln_service b/simulations/mixnet/test_rln_service new file mode 100755 index 000000000..33dd83064 Binary files /dev/null and b/simulations/mixnet/test_rln_service differ diff --git a/simulations/mixnet/test_rln_service.nim b/simulations/mixnet/test_rln_service.nim new file mode 100644 index 000000000..bb448e968 --- /dev/null +++ b/simulations/mixnet/test_rln_service.nim @@ -0,0 +1,71 @@ +## Quick smoke test for the external RLN JSON-RPC service. +## +## Usage: nim c -r test_rln_service.nim [http://127.0.0.1:3001] + +import std/[httpclient, json, os, strutils] + +const DefaultUrl = "http://127.0.0.1:3001" + +proc jsonRpc(client: HttpClient, url, methodName: string, + params: JsonNode = newJArray()): JsonNode = + let body = %*{ + "jsonrpc": "2.0", + "method": methodName, + "params": params, + "id": 1 + } + let resp = client.request(url, httpMethod = HttpPost, + body = $body, + headers = newHttpHeaders({"Content-Type": "application/json"})) + let parsed = parseJson(resp.body) + if parsed.hasKey("error"): + echo " ERROR: ", parsed["error"] + return nil + return parsed["result"] + +proc main() = + let url = if paramCount() >= 1: paramStr(1) else: DefaultUrl + echo "Testing RLN service at ", url + let client = newHttpClient() + + # 1. Get root + echo "\n--- rln_getRoot ---" + let root = client.jsonRpc(url, "rln_getRoot") + if root != nil: + echo " root: ", root.getStr() + + # 2. Register a dummy identity commitment + echo "\n--- rln_register ---" + # 32-byte hex commitment (64 hex chars) — just a test value + let testCommitment = "0x" & "ab".repeat(32) + let testLimit = 100 + let regResult = client.jsonRpc(url, "rln_register", + %*[testCommitment, testLimit]) + if regResult != nil: + echo " result: ", regResult + + # 3. Get root again (should have changed after registration) + echo "\n--- rln_getRoot (after register) ---" + let root2 = client.jsonRpc(url, "rln_getRoot") + if root2 != nil: + echo " root: ", root2.getStr() + if root != nil: + echo " changed: ", root.getStr() != root2.getStr() + + # 4. Get merkle proof for index 0 + echo "\n--- rln_getMerkleProof(0) ---" + let proof = client.jsonRpc(url, "rln_getMerkleProof", %*[0]) + if proof != nil: + if proof.kind == JObject: + for key, val in proof.pairs: + let s = $val + if s.len > 200: + echo " ", key, ": ", s[0..196], "..." + else: + echo " ", key, ": ", s + else: + echo " result: ", proof + + echo "\nDone." + +main() diff --git a/simulations/mixnet/typescript b/simulations/mixnet/typescript new file mode 100644 index 000000000..e69de29bb diff --git a/tools/confutils/cli_args.nim b/tools/confutils/cli_args.nim index 5e4adacb2..5829a5bd1 100644 --- a/tools/confutils/cli_args.nim +++ b/tools/confutils/cli_args.nim @@ -621,6 +621,13 @@ with the drawback of consuming some more bandwidth.""", name: "mixnode" .}: seq[MixNodePubInfo] + rlnServiceUrl* {. + desc: + "URL of the external RLN Merkle proof service (required for group manager)", + defaultValue: "", + name: "rln-service-url" + .}: string + # Kademlia Discovery config enableKadDiscovery* {. desc: @@ -1021,6 +1028,7 @@ proc toWakuConf*(n: WakuNodeConf): ConfResult[WakuConf] = b.mixConf.withEnabled(n.mix) b.mixConf.withMixNodes(n.mixnodes) + b.mixConf.withRlnServiceUrl(n.rlnServiceUrl) b.withMix(n.mix) if n.mixkey.isSome(): b.mixConf.withMixKey(n.mixkey.get()) diff --git a/vendor/mix-rln-spam-protection-plugin b/vendor/mix-rln-spam-protection-plugin index b3e3f7293..38f8ac8d6 160000 --- a/vendor/mix-rln-spam-protection-plugin +++ b/vendor/mix-rln-spam-protection-plugin @@ -1 +1 @@ -Subproject commit b3e3f72932d53fe0959e182fc081fe14c0c2c2f0 +Subproject commit 38f8ac8d6fcf06afb1dd1f4490887a4ddd8a331a diff --git a/waku/factory/conf_builder/mix_conf_builder.nim b/waku/factory/conf_builder/mix_conf_builder.nim index 145ccb76e..9b54ba457 100644 --- a/waku/factory/conf_builder/mix_conf_builder.nim +++ b/waku/factory/conf_builder/mix_conf_builder.nim @@ -12,6 +12,7 @@ type MixConfBuilder* = object enabled: Option[bool] mixKey: Option[string] mixNodes: seq[MixNodePubInfo] + rlnServiceUrl: string proc init*(T: type MixConfBuilder): MixConfBuilder = MixConfBuilder() @@ -25,19 +26,24 @@ proc withMixKey*(b: var MixConfBuilder, mixKey: string) = proc withMixNodes*(b: var MixConfBuilder, mixNodes: seq[MixNodePubInfo]) = b.mixNodes = mixNodes +proc withRlnServiceUrl*(b: var MixConfBuilder, url: string) = + b.rlnServiceUrl = url + proc build*(b: MixConfBuilder): Result[Option[MixConf], string] = if not b.enabled.get(false): return ok(none[MixConf]()) + + if b.mixKey.isSome(): + let mixPrivKey = intoCurve25519Key(ncrutils.fromHex(b.mixKey.get())) + let mixPubKey = public(mixPrivKey) + return ok(some(MixConf( + mixKey: mixPrivKey, mixPubKey: mixPubKey, mixNodes: b.mixNodes, + rlnServiceUrl: b.rlnServiceUrl, + ))) else: - if b.mixKey.isSome(): - let mixPrivKey = intoCurve25519Key(ncrutils.fromHex(b.mixKey.get())) - let mixPubKey = public(mixPrivKey) - return ok( - some(MixConf(mixKey: mixPrivKey, mixPubKey: mixPubKey, mixNodes: b.mixNodes)) - ) - else: - let (mixPrivKey, mixPubKey) = generateKeyPair().valueOr: - return err("Generate key pair error: " & $error) - return ok( - some(MixConf(mixKey: mixPrivKey, mixPubKey: mixPubKey, mixNodes: b.mixNodes)) - ) + let (mixPrivKey, mixPubKey) = generateKeyPair().valueOr: + return err("Generate key pair error: " & $error) + return ok(some(MixConf( + mixKey: mixPrivKey, mixPubKey: mixPubKey, mixNodes: b.mixNodes, + rlnServiceUrl: b.rlnServiceUrl, + ))) diff --git a/waku/factory/node_factory.nim b/waku/factory/node_factory.nim index 2f82440f6..9ba43543e 100644 --- a/waku/factory/node_factory.nim +++ b/waku/factory/node_factory.nim @@ -168,7 +168,10 @@ proc setupProtocols( #mount mix if conf.mixConf.isSome(): let mixConf = conf.mixConf.get() - (await node.mountMix(conf.clusterId, mixConf.mixKey, mixConf.mixnodes)).isOkOr: + (await node.mountMix( + conf.clusterId, mixConf.mixKey, mixConf.mixnodes, + rlnServiceUrl = mixConf.rlnServiceUrl, + )).isOkOr: return err("failed to mount waku mix protocol: " & $error) # Setup extended kademlia discovery diff --git a/waku/factory/waku_conf.nim b/waku/factory/waku_conf.nim index 01574d067..0b04a45dc 100644 --- a/waku/factory/waku_conf.nim +++ b/waku/factory/waku_conf.nim @@ -51,6 +51,7 @@ type MixConf* = ref object mixKey*: Curve25519Key mixPubKey*: Curve25519Key mixnodes*: seq[MixNodePubInfo] + rlnServiceUrl*: string type KademliaDiscoveryConf* = object bootstrapNodes*: seq[(PeerId, seq[MultiAddress])] diff --git a/waku/node/waku_node.nim b/waku/node/waku_node.nim index 6f869e364..18a25f4c7 100644 --- a/waku/node/waku_node.nim +++ b/waku/node/waku_node.nim @@ -327,6 +327,7 @@ proc mountMix*( mixPrivKey: Curve25519Key, mixnodes: seq[MixNodePubInfo], userMessageLimit: Option[int] = none(int), + rlnServiceUrl: string = "", ): Future[Result[void, string]] {.async.} = info "mounting mix protocol", nodeId = node.info #TODO log the config used @@ -359,7 +360,7 @@ proc mountMix*( node.wakuMix = WakuMix.new( localaddrStr, node.peerManager, clusterId, mixPrivKey, mixnodes, publishMessage, - userMessageLimit, + userMessageLimit, rlnServiceUrl, ).valueOr: error "Waku Mix protocol initialization failed", err = error return diff --git a/waku/waku_mix/protocol.nim b/waku/waku_mix/protocol.nim index d1c44b588..fc5c7afa1 100644 --- a/waku/waku_mix/protocol.nim +++ b/waku/waku_mix/protocol.nim @@ -21,7 +21,8 @@ import waku/node/peer_manager/waku_peer_store, mix_rln_spam_protection, waku/waku_relay, - waku/common/nimchronos + waku/common/nimchronos, + ./rln_service_client logScope: topics = "waku mix" @@ -87,6 +88,7 @@ proc new*( bootnodes: seq[MixNodePubInfo], publishMessage: PublishMessage, userMessageLimit: Option[int] = none(int), + rlnServiceUrl: string = "", ): WakuMixResult[T] = let mixPubKey = public(mixPrivKey) trace "mixPubKey", mixPubKey = mixPubKey @@ -113,6 +115,14 @@ proc new*( let spamProtection = newMixRlnSpamProtection(spamProtectionConfig).valueOr: return err("failed to create spam protection: " & error) + # Wire up RLN service callbacks + if rlnServiceUrl.len == 0: + return err("rlnServiceUrl is required for group manager") + spamProtection.setMerkleProofCallbacks( + makeFetchMerkleProof(rlnServiceUrl), + makeFetchLatestRoots(rlnServiceUrl), + ) + var m = WakuMix( peerManager: peermgr, clusterId: clusterId, @@ -179,22 +189,7 @@ proc handleMessage*( let contentTopic = message.contentTopic - if contentTopic == mix.mixRlnSpamProtection.getMembershipContentTopic(): - # Handle membership update - let res = await mix.mixRlnSpamProtection.handleMembershipUpdate(message.payload) - if res.isErr: - warn "Failed to handle membership update", error = res.error - else: - trace "Handled membership update" - - # Persist tree after membership changes (temporary solution) - # TODO: Replace with proper persistence strategy (e.g., periodic snapshots) - let saveRes = mix.mixRlnSpamProtection.saveTree() - if saveRes.isErr: - debug "Failed to save tree after membership update", error = saveRes.error - else: - trace "Saved tree after membership update" - elif contentTopic == mix.mixRlnSpamProtection.getProofMetadataContentTopic(): + if contentTopic == mix.mixRlnSpamProtection.getProofMetadataContentTopic(): # Handle proof metadata for network-wide spam detection let res = mix.mixRlnSpamProtection.handleProofMetadata(message.payload) if res.isErr: @@ -209,31 +204,6 @@ proc getSpamProtectionContentTopics*(mix: WakuMix): seq[string] = return @[] return mix.mixRlnSpamProtection.getContentTopics() -proc saveSpamProtectionTree*(mix: WakuMix): Result[void, string] = - ## Save the spam protection membership tree to disk. - ## This allows preserving the tree state across restarts. - if mix.mixRlnSpamProtection.isNil(): - return err("Spam protection not initialized") - - mix.mixRlnSpamProtection.saveTree().mapErr( - proc(e: string): string = - e - ) - -proc loadSpamProtectionTree*(mix: WakuMix): Result[void, string] = - ## Load the spam protection membership tree from disk. - ## Call this before init() to restore tree state from previous runs. - ## TODO: This is a temporary solution. Ideally nodes should sync tree state - ## via a store query for historical membership messages or via dedicated - ## tree sync protocol. - if mix.mixRlnSpamProtection.isNil(): - return err("Spam protection not initialized") - - mix.mixRlnSpamProtection.loadTree().mapErr( - proc(e: string): string = - e - ) - method start*(mix: WakuMix) {.async.} = info "starting waku mix protocol" @@ -244,23 +214,6 @@ method start*(mix: WakuMix) {.async.} = if initRes.isErr: error "Failed to initialize spam protection", error = initRes.error else: - # Load existing tree to sync with other members - # This should be done after init() (which loads credentials) - # but before registerSelf() (which adds us to the tree) - let loadRes = mix.mixRlnSpamProtection.loadTree() - if loadRes.isErr: - debug "No existing tree found or failed to load, starting fresh", - error = loadRes.error - else: - debug "Loaded existing spam protection membership tree from disk" - - # Restore our credentials to the tree (after tree load, whether it succeeded or not) - # This ensures our member is in the tree if we have an index from keystore - let restoreRes = mix.mixRlnSpamProtection.restoreCredentialsToTree() - if restoreRes.isErr: - error "Failed to restore credentials to tree", error = restoreRes.error - - # Set up publish callback (must be before start so registerSelf can use it) mix.setupSpamProtectionCallbacks() let startRes = await mix.mixRlnSpamProtection.start() @@ -275,13 +228,6 @@ method start*(mix: WakuMix) {.async.} = else: debug "Registered spam protection credentials", index = registerRes.get() - # Save tree to persist membership state - let saveRes = mix.mixRlnSpamProtection.saveTree() - if saveRes.isErr: - warn "Failed to save spam protection tree", error = saveRes.error - else: - trace "Saved spam protection tree to disk" - method stop*(mix: WakuMix) {.async.} = # Stop spam protection if not mix.mixRlnSpamProtection.isNil(): diff --git a/waku/waku_mix/rln_service_client.nim b/waku/waku_mix/rln_service_client.nim new file mode 100644 index 000000000..3bc0bd93f --- /dev/null +++ b/waku/waku_mix/rln_service_client.nim @@ -0,0 +1,154 @@ +{.push raises: [].} + +## JSON-RPC client for the external RLN Merkle proof service. +## +## Provides factory functions that create the callback procs expected by +## GroupManager (FetchLatestRootsCallback, FetchMerkleProofCallback). + +import std/[json, strutils] +import chronos +import chronos/apps/http/[httpclient, httpcommon] +import results +import stew/byteutils +import chronicles + +import mix_rln_spam_protection/types + +logScope: + topics = "waku mix rln-service" + +# ============================================================================= +# Helpers +# ============================================================================= + +proc jsonRpcCall( + session: HttpSessionRef, address: HttpAddress, methodName: string, + params: string = "[]", +): Future[JsonNode] {.async.} = + ## Make a JSON-RPC call and return the result field. + let body = """{"jsonrpc":"2.0","method":"""" & methodName & + """","params":""" & params & ""","id":1}""" + + var req: HttpClientRequestRef = nil + var res: HttpClientResponseRef = nil + try: + req = HttpClientRequestRef.post( + session, address, + body = body.toOpenArrayByte(0, body.len - 1), + headers = @[("Content-Type", "application/json")], + ) + res = await req.send() + let resBytes = await res.getBodyBytes() + let parsed = parseJson(string.fromBytes(resBytes)) + + if parsed.hasKey("error"): + let errMsg = $parsed["error"] + raise newException(CatchableError, "JSON-RPC error: " & errMsg) + + if not parsed.hasKey("result"): + raise newException(CatchableError, "JSON-RPC response missing 'result'") + + return parsed["result"] + finally: + if req != nil: + await req.closeWait() + if res != nil: + await res.closeWait() + +proc hexToBytes32(hex: string): RlnResult[array[32, byte]] = + ## Parse a "0x..." hex string into a 32-byte array. + var h = hex + if h.startsWith("0x") or h.startsWith("0X"): + h = h[2 .. ^1] + if h.len != 64: + return err("Expected 64 hex chars, got " & $h.len) + var output: array[32, byte] + for i in 0 ..< 32: + try: + output[i] = byte(parseHexInt(h[i * 2 .. i * 2 + 1])) + except ValueError: + return err("Invalid hex at position " & $i) + ok(output) + +# ============================================================================= +# Callback factories +# ============================================================================= + +proc makeFetchLatestRoots*( + serviceUrl: string +): FetchLatestRootsCallback = + ## Create a callback that fetches the latest valid Merkle roots from the + ## RLN service via rln_getRoots. Returns 1–5 roots, newest first. + let session = HttpSessionRef.new() + let address = session.getAddress(serviceUrl) + if address.isErr: + warn "Invalid RLN service URL", url = serviceUrl, error = address.error + return proc(): Future[RlnResult[seq[MerkleNode]]] {.async, gcsafe, raises: [].} = + return err("Invalid RLN service URL: " & serviceUrl) + + let httpAddress = address.get() + + return proc(): Future[RlnResult[seq[MerkleNode]]] {.async, gcsafe, raises: [].} = + try: + let resultJson = await jsonRpcCall(session, httpAddress, "rln_getRoots") + var roots: seq[MerkleNode] + for elem in resultJson: + let root = hexToBytes32(elem.getStr()).valueOr: + return err("Invalid root hex: " & error) + roots.add(MerkleNode(root)) + return ok(roots) + except CatchableError as e: + debug "Failed to fetch latest roots", error = e.msg + return err("Failed to fetch roots: " & e.msg) + +proc makeFetchMerkleProof*( + serviceUrl: string +): FetchMerkleProofCallback = + ## Create a callback that fetches a Merkle proof from the RLN service. + let session = HttpSessionRef.new() + let address = session.getAddress(serviceUrl) + if address.isErr: + warn "Invalid RLN service URL", url = serviceUrl, error = address.error + return proc( + index: MembershipIndex + ): Future[RlnResult[ExternalMerkleProof]] {.async, gcsafe, raises: [].} = + return err("Invalid RLN service URL: " & serviceUrl) + + let httpAddress = address.get() + + return proc( + index: MembershipIndex + ): Future[RlnResult[ExternalMerkleProof]] {.async, gcsafe, raises: [].} = + try: + let resultJson = await jsonRpcCall( + session, httpAddress, "rln_getMerkleProof", "[" & $index & "]" + ) + + # Parse root + let rootHex = resultJson["root"].getStr() + let root = hexToBytes32(rootHex).valueOr: + return err("Invalid root hex: " & error) + + # Parse pathElements: array of "0x..." hex strings -> concatenated bytes + var pathElements: seq[byte] + for elem in resultJson["pathElements"]: + let elemBytes = hexToBytes32(elem.getStr()).valueOr: + return err("Invalid pathElement hex: " & error) + for b in elemBytes: + pathElements.add(b) + + # Parse identityPathIndex: array of ints -> byte per level + var identityPathIndex: seq[byte] + for idx in resultJson["identityPathIndex"]: + identityPathIndex.add(byte(idx.getInt())) + + return ok(ExternalMerkleProof( + pathElements: pathElements, + identityPathIndex: identityPathIndex, + root: MerkleNode(root), + )) + except CatchableError as e: + debug "Failed to fetch Merkle proof", index = index, error = e.msg + return err("Failed to fetch Merkle proof: " & e.msg) + +{.pop.}