diff --git a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim index 6af5bb0f2..a4c8a982b 100644 --- a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim +++ b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim @@ -307,7 +307,7 @@ suite "Onchain group manager": validProofRes.isOk() let validProof = validProofRes.get() - let validated = manager.validateRoot(validProof.merkleRoot) + let validated = waitFor manager.validateRoot(validProof.merkleRoot) check: validated @@ -343,11 +343,90 @@ suite "Onchain group manager": validProofRes.isOk() let validProof = validProofRes.get() - let validated = manager.validateRoot(validProof.merkleRoot) + let validated = waitFor manager.validateRoot(validProof.merkleRoot) check: validated == false + test "validateRoot: refreshes from on-chain when root is unknown locally": + # Without an explicit updateRecentRoots, validateRoot must detect a + # missing root, refresh the local window from the contract cache, and + # find the root there. + const credentialCount = 3 + let credentials = generateCredentials(credentialCount) + (waitFor manager.init()).isOkOr: + raiseAssert $error + + for i in 0 ..< credentials.len(): + (waitFor manager.register(credentials[i], UserMessageLimit(20))).isOkOr: + assert false, "Failed to register credential " & $i & ": " & error + + let currentRoot = (waitFor manager.fetchMerkleRoot()).valueOr: + raiseAssert "Failed to fetch merkle root: " & error + let rootField = UInt256ToField(currentRoot) + + check manager.validRoots.len() == 0 + + let validated = waitFor manager.validateRoot(rootField) + + check: + validated + manager.validRoots.len() > 0 + + test "validateRoot: debounces refresh requests within the interval": + # After one refresh, a second miss within RootRefreshDebounceInterval + # must return false without issuing another RPC — observable via + # lastRefreshAt staying pinned to the first refresh's timestamp. + let credentials = generateCredentials() + (waitFor manager.init()).isOkOr: + raiseAssert $error + + (waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr: + assert false, "register failed: " & error + + var bogusRoot: MerkleNode + for i in 0 ..< bogusRoot.len: + bogusRoot[i] = 0xAB'u8 + + let firstResult = waitFor manager.validateRoot(bogusRoot) + let firstRefreshAt = manager.lastRefreshAt + + let secondResult = waitFor manager.validateRoot(bogusRoot) + + check: + not firstResult + not secondResult + manager.lastRefreshAt == firstRefreshAt + + test "validateRoot: concurrent misses share a single in-flight refresh": + # Two validateRoot calls kicked off before the first's refresh resolves + # must both end up `true` for the new root. Without coalescing, the + # second call would fall through to the debounce gate and return false. + const credentialCount = 3 + let credentials = generateCredentials(credentialCount) + (waitFor manager.init()).isOkOr: + raiseAssert $error + + for i in 0 ..< credentials.len(): + (waitFor manager.register(credentials[i], UserMessageLimit(20))).isOkOr: + assert false, "Failed to register credential " & $i & ": " & error + + let currentRoot = (waitFor manager.fetchMerkleRoot()).valueOr: + raiseAssert "Failed to fetch merkle root: " & error + let rootField = UInt256ToField(currentRoot) + + check manager.validRoots.len() == 0 + + let f1 = manager.validateRoot(rootField) + let f2 = manager.validateRoot(rootField) + + waitFor allFutures(f1, f2) + + check: + f1.read() + f2.read() + manager.validRoots.len() > 0 + test "verifyProof: should verify valid proof": let credentials = generateCredentials() (waitFor manager.init()).isOkOr: diff --git a/tests/waku_rln_relay/test_waku_rln_relay.nim b/tests/waku_rln_relay/test_waku_rln_relay.nim index 7694b8112..3b17f55fa 100644 --- a/tests/waku_rln_relay/test_waku_rln_relay.nim +++ b/tests/waku_rln_relay/test_waku_rln_relay.nim @@ -269,13 +269,13 @@ suite "Waku rln relay": # Validate messages let - msgValidate1 = wakuRlnRelay.validateMessageAndUpdateLog(wm1) + msgValidate1 = await wakuRlnRelay.validateMessageAndUpdateLog(wm1) # wm2 is within the same epoch as wm1 → should be spam - msgValidate2 = wakuRlnRelay.validateMessageAndUpdateLog(wm2) + msgValidate2 = await wakuRlnRelay.validateMessageAndUpdateLog(wm2) # wm3 is in the next epoch → should be valid - msgValidate3 = wakuRlnRelay.validateMessageAndUpdateLog(wm3) + msgValidate3 = await wakuRlnRelay.validateMessageAndUpdateLog(wm3) # wm4 has no RLN proof → should be invalid - msgValidate4 = wakuRlnRelay.validateMessageAndUpdateLog(wm4) + msgValidate4 = await wakuRlnRelay.validateMessageAndUpdateLog(wm4) check: msgValidate1 == MessageValidationResult.Valid @@ -323,12 +323,12 @@ suite "Waku rln relay": raiseAssert $error # validate the first message because it's timestamp is the same as the generated timestamp - let msgValidate1 = wakuRlnRelay.validateMessageAndUpdateLog(wm1) + let msgValidate1 = await wakuRlnRelay.validateMessageAndUpdateLog(wm1) # wait for 2 seconds to make the timestamp different from generated timestamp await sleepAsync(2.seconds) - let msgValidate2 = wakuRlnRelay.validateMessageAndUpdateLog(wm2) + let msgValidate2 = await wakuRlnRelay.validateMessageAndUpdateLog(wm2) check: msgValidate1 == MessageValidationResult.Valid @@ -378,8 +378,8 @@ suite "Waku rln relay": raiseAssert $error let - msgValidate1 = wakuRlnRelay1.validateMessageAndUpdateLog(wm1) - msgValidate2 = wakuRlnRelay1.validateMessageAndUpdateLog(wm2) + msgValidate1 = await wakuRlnRelay1.validateMessageAndUpdateLog(wm1) + msgValidate2 = await wakuRlnRelay1.validateMessageAndUpdateLog(wm2) check: msgValidate1 == MessageValidationResult.Valid diff --git a/waku/waku_rln_relay/group_manager/group_manager_base.nim b/waku/waku_rln_relay/group_manager/group_manager_base.nim index 9c088d4c5..ceda39a51 100644 --- a/waku/waku_rln_relay/group_manager/group_manager_base.nim +++ b/waku/waku_rln_relay/group_manager/group_manager_base.nim @@ -121,9 +121,7 @@ method indexOfRoot*( ## returns the index of the root in the merkle tree and returns -1 if the root is not found return g.validRoots.find(root) -method validateRoot*( - g: GroupManager, root: MerkleNode -): bool {.base, gcsafe, raises: [].} = +method validateRoot*(g: GroupManager, root: MerkleNode): Future[bool] {.base, async.} = ## validates the root against the valid roots queue return g.indexOfRoot(root) >= 0 diff --git a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim index 02b463077..c71987137 100644 --- a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim +++ b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim @@ -28,6 +28,10 @@ export group_manager_base logScope: topics = "waku rln_relay onchain_group_manager" +const RootRefreshDebounceInterval* = 1.seconds + ## Minimum gap between on-demand recent-roots refreshes triggered by + ## `validateRoot` misses, to bound contract-call rate under spam. + type WakuRlnContractWithSender = Sender[WakuRlnContract] OnchainGroupManager* = ref object of GroupManager @@ -43,6 +47,10 @@ type registrationHandler*: Option[RegistrationHandler] latestProcessedBlock*: BlockNumber merkleProofCache*: seq[byte] + lastRefreshAt*: Moment + pendingRefresh*: Future[bool] + ## Non-nil while an on-demand recent-roots refresh is in flight, so that + ## concurrent `validateRoot` misses can ride along on a single RPC. # The below code is not working with the latest web3 version due to chainId being null (specifically on linea-sepolia) # TODO: find better solution than this custom sendEthCallWithoutParams call @@ -474,6 +482,31 @@ method generateProof*( waku_rln_total_generated_proofs.inc() return ok(output) +method validateRoot*(g: OnchainGroupManager, root: MerkleNode): Future[bool] {.async.} = + ## Validates the root against the local valid roots window. If the root is + ## not found, refresh the local window from the on-chain recent-roots cache + ## and re-check once before giving up. Concurrent misses share a single + ## in-flight refresh; new refreshes are debounced by + ## `RootRefreshDebounceInterval` to bound contract-call rate. + if g.indexOfRoot(root) >= 0: + return true + + # Coalesce: if a refresh is already in flight, ride along instead of starting a new one. + if not g.pendingRefresh.isNil and not g.pendingRefresh.finished: + discard await g.pendingRefresh + return g.indexOfRoot(root) >= 0 + + # Debounce: don't queue another refresh too soon after the previous one. + let now = Moment.now() + if now - g.lastRefreshAt < RootRefreshDebounceInterval: + return false + + g.lastRefreshAt = now + g.pendingRefresh = g.updateRecentRoots() + discard await g.pendingRefresh + + return g.indexOfRoot(root) >= 0 + method verifyProof*( g: OnchainGroupManager, input: seq[byte], proof: RateLimitProof ): GroupManagerResult[bool] {.gcsafe.} = diff --git a/waku/waku_rln_relay/rln_relay.nim b/waku/waku_rln_relay/rln_relay.nim index 7c36300b2..d6e9cd069 100644 --- a/waku/waku_rln_relay/rln_relay.nim +++ b/waku/waku_rln_relay/rln_relay.nim @@ -177,7 +177,7 @@ proc toRLNSignal*(wakumessage: WakuMessage): seq[byte] = proc validateMessage*( rlnPeer: WakuRLNRelay, msg: WakuMessage -): MessageValidationResult = +): Future[MessageValidationResult] {.async.} = ## validate the supplied `msg` based on the waku-rln-relay routing protocol i.e., ## the `msg`'s epoch is within MaxEpochGap of the current epoch ## the `msg` has valid rate limit proof @@ -217,7 +217,7 @@ proc validateMessage*( waku_rln_invalid_messages_total.inc(labelValues = ["timestamp_mismatch"]) return MessageValidationResult.Invalid - let rootValidationRes = rlnPeer.groupManager.validateRoot(proof.merkleRoot) + let rootValidationRes = await rlnPeer.groupManager.validateRoot(proof.merkleRoot) if not rootValidationRes: warn "invalid message: provided root does not belong to acceptable window of roots", provided = proof.merkleRoot.inHex(), @@ -272,11 +272,11 @@ proc validateMessage*( proc validateMessageAndUpdateLog*( rlnPeer: WakuRLNRelay, msg: WakuMessage -): MessageValidationResult = +): Future[MessageValidationResult] {.async.} = ## validates the message and updates the log to prevent double messaging ## in future messages - let isValidMessage = rlnPeer.validateMessage(msg) + let isValidMessage = await rlnPeer.validateMessage(msg) let msgProof = RateLimitProof.init(msg.proof).valueOr: return MessageValidationResult.Invalid @@ -353,7 +353,7 @@ proc generateRlnValidator*( return pubsub.ValidationResult.Reject # validate the message and update log - let validationRes = wakuRlnRelay.validateMessageAndUpdateLog(message) + let validationRes = await wakuRlnRelay.validateMessageAndUpdateLog(message) let proof = byteutils.toHex(msgProof.proof)