replaces off-chain manager with service over LEZ

This commit is contained in:
Arseniy Klempner 2026-02-24 22:18:16 -06:00
parent 29a40fe486
commit e66a10bf3e
No known key found for this signature in database
GPG Key ID: 51653F18863BD24B
26 changed files with 558 additions and 141 deletions

View File

@ -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

View File

@ -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:

View File

@ -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
```

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

Binary file not shown.

View File

@ -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()

Binary file not shown.

View File

@ -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()

Binary file not shown.

View File

@ -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()

View File

View File

@ -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())

@ -1 +1 @@
Subproject commit b3e3f72932d53fe0959e182fc081fe14c0c2c2f0
Subproject commit 38f8ac8d6fcf06afb1dd1f4490887a4ddd8a331a

View File

@ -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,
)))

View File

@ -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

View File

@ -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])]

View File

@ -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

View File

@ -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():

View File

@ -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 15 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.}