Merge branch 'master' into chat2disco

This commit is contained in:
Simon-Pierre Vivier 2026-07-16 10:12:30 -04:00 committed by GitHub
commit 8dd9063728
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1011 additions and 151 deletions

View File

@ -16,6 +16,7 @@ import
logos_delivery/waku/[
waku_core,
node/waku_node,
node/waku_node/lightpush,
node/peer_manager,
waku_relay/protocol,
rln,
@ -50,11 +51,13 @@ proc lightpushPeerAvailable*(self: Waku, shard: PubsubTopic): bool =
proc lightpushPublishToAny*(
self: Waku, shard: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult] {.async.} =
## Selects a lightpush service peer for `shard` and publishes `message`.
## Returns SERVICE_NOT_AVAILABLE when no peer is available.
## Selects a lightpush service peer for `shard` and publishes `message`
## through the node's lightpush flow, which attaches an RLN proof per
## attempt when RLN is mounted. Returns SERVICE_NOT_AVAILABLE when no peer
## is available.
let peer = self.node.peerManager.selectPeer(WakuLightPushCodec, some(shard)).valueOr:
return lightpushResultServiceUnavailable("no lightpush peer available for shard")
try:
return await self.node.wakuLightpushClient.publish(some(shard), message, peer)
return await self.node.lightpushPublish(some(shard), message, some(peer))
except CatchableError as e:
return lightpushResultInternalError(e.msg)

View File

@ -2,7 +2,7 @@ import logos_delivery/waku/compat/option_valueor
{.push raises: [].}
import
std/[hashes, options, tables, net],
std/[hashes, options, strutils, tables, net],
chronos,
chronicles,
metrics,
@ -67,6 +67,67 @@ proc mountLegacyLightPushClient*(node: WakuNode) =
node.wakuLegacyLightpushClient =
WakuLegacyLightPushClient.new(node.peerManager, node.rng)
proc internalLegacyLightpushPublish(
node: WakuNode, pubsubTopic: PubsubTopic, message: WakuMessage, peer: RemotePeerInfo
): Future[legacy_lightpush_protocol.WakuLightPushResult[string]] {.async, gcsafe.} =
## Dispatches to the legacy lightpush client if mounted, otherwise to the
## self-hosted server. Callers guarantee at least one is mounted.
let msgHash = pubsubTopic.computeMessageHash(message).to0xHex()
if not node.wakuLegacyLightpushClient.isNil():
notice "publishing message with legacy lightpush",
pubsubTopic = pubsubTopic,
contentTopic = message.contentTopic,
target_peer_id = peer.peerId,
msg_hash = msgHash
return await node.wakuLegacyLightpushClient.publish(pubsubTopic, message, peer)
notice "publishing message with self hosted legacy lightpush",
pubsubTopic = pubsubTopic,
contentTopic = message.contentTopic,
target_peer_id = peer.peerId,
msg_hash = msgHash
return await node.wakuLegacyLightPush.handleSelfLightPushRequest(pubsubTopic, message)
proc resolveLegacyPubsubTopic(
node: WakuNode, pubsubTopic: Option[PubsubTopic], contentTopic: ContentTopic
): Result[PubsubTopic, string] =
## Returns the explicit pubsub topic, else derives it from `contentTopic`
## via autosharding. The legacy wire format requires a pubsub topic and the
## server never derives it, so the client must resolve it here.
if pubsubTopic.isSome():
return ok(pubsubTopic.get())
if node.wakuAutoSharding.isNone():
return err("Pubsub topic must be specified when static sharding is enabled")
let parsedTopic = NsContentTopic.parse(contentTopic).valueOr:
return err("Invalid content-topic: " & $error)
let shard = node.wakuAutoSharding.get().getShard(parsedTopic).valueOr:
return err("Autosharding error: " & error)
return ok($shard)
proc runRlnRefreshRetry(
node: WakuNode,
rln: Option[Rln],
msgWithProof: WakuMessage,
pubsubForPublish: PubsubTopic,
peer: RemotePeerInfo,
fallback: legacy_lightpush_protocol.WakuLightPushResult[string],
): Future[legacy_lightpush_protocol.WakuLightPushResult[string]] {.async, gcsafe.} =
## Refreshes the RLN merkle proof path and retries the publish once. Only the
## refresh is bounded by RlnMerkleProofRefreshTimeout (returning `fallback` on
## timeout); the retried publish runs unbounded, matching the first attempt.
info "legacy lightpush send rejected as RLN-invalid; " &
"refreshing merkle proof and retrying once"
rln.get().groupManager.invalidateMerkleProofCache()
let refreshFut = attachRLNProof(rln.get(), msgWithProof)
if not (await refreshFut.withTimeout(RlnMerkleProofRefreshTimeout)):
warn "legacy lightpush RLN proof refresh timed out; returning original error"
return fallback
let retryMsg = refreshFut.read().valueOr:
return err("failed call attachRLNProof from lightpush retry: " & error)
return await internalLegacyLightpushPublish(node, pubsubForPublish, retryMsg, peer)
proc legacyLightpushPublish*(
node: WakuNode,
pubsubTopic: Option[PubsubTopic],
@ -81,9 +142,8 @@ proc legacyLightpushPublish*(
error "failed to publish message as legacy lightpush not available"
return err("Waku lightpush not available")
# toRLNSignal includes the timestamp in the proof input, so the timestamp
# must be fixed before proof generation. The downstream ensureTimestampSet
# in the client publish becomes an idempotent no-op safety net.
# toRLNSignal hashes the timestamp into the proof, so fix it before proof gen;
# the downstream ensureTimestampSet then becomes a no-op.
let message = ensureTimestampSet(message)
let rln =
@ -94,40 +154,24 @@ proc legacyLightpushPublish*(
let msgWithProof = (await checkAndGenerateRLNProof(rln, message)).valueOr:
return err("failed call checkAndGenerateRLNProof from lightpush: " & error)
let internalPublish = proc(
node: WakuNode,
pubsubTopic: PubsubTopic,
message: WakuMessage,
peer: RemotePeerInfo,
): Future[legacy_lightpush_protocol.WakuLightPushResult[string]] {.async, gcsafe.} =
let msgHash = pubsubTopic.computeMessageHash(message).to0xHex()
if not node.wakuLegacyLightpushClient.isNil():
notice "publishing message with legacy lightpush",
pubsubTopic = pubsubTopic,
contentTopic = message.contentTopic,
target_peer_id = peer.peerId,
msg_hash = msgHash
return await node.wakuLegacyLightpushClient.publish(pubsubTopic, message, peer)
if not node.wakuLegacyLightPush.isNil():
notice "publishing message with self hosted legacy lightpush",
pubsubTopic = pubsubTopic,
contentTopic = message.contentTopic,
target_peer_id = peer.peerId,
msg_hash = msgHash
return
await node.wakuLegacyLightPush.handleSelfLightPushRequest(pubsubTopic, message)
try:
if pubsubTopic.isSome():
return await internalPublish(node, pubsubTopic.get(), msgWithProof, peer)
let pubsubForPublish = resolveLegacyPubsubTopic(
node, pubsubTopic, message.contentTopic
).valueOr:
return err(error)
if node.wakuAutoSharding.isNone():
return err("Pubsub topic must be specified when static sharding is enabled")
let topicMap =
?node.wakuAutoSharding.get().getShardsFromContentTopics(message.contentTopic)
let firstResult =
await internalLegacyLightpushPublish(node, pubsubForPublish, msgWithProof, peer)
for pubsub, _ in topicMap.pairs: # There's only one pair anyway
return await internalPublish(node, $pubsub, msgWithProof, peer)
# Legacy has no status codes, so string-match the RLN error to detect a
# stale merkle proof path, then refresh and retry once.
if firstResult.isOk() or rln.isNone() or
not firstResult.error.contains(RlnValidatorErrorMsg):
return firstResult
return await runRlnRefreshRetry(
node, rln, msgWithProof, pubsubForPublish, peer, firstResult
)
except CatchableError:
return err(getCurrentExceptionMsg())
@ -279,9 +323,8 @@ proc lightpushPublish*(
error "lightpush publish error", error = msg
return lighpushErrorResult(LightPushErrorCode.INTERNAL_SERVER_ERROR, msg)
# toRLNSignal includes the timestamp in the proof input, so the timestamp
# must be fixed before proof generation. The downstream ensureTimestampSet
# in the client publish becomes an idempotent no-op safety net.
# toRLNSignal hashes the timestamp into the proof, so fix it before proof gen;
# the downstream ensureTimestampSet then becomes a no-op.
let message = ensureTimestampSet(message)
let rln =
@ -292,5 +335,30 @@ proc lightpushPublish*(
let msgWithProof = (await checkAndGenerateRLNProof(rln, message)).valueOr:
return lighpushErrorResult(LightPushErrorCode.OUT_OF_RLN_PROOF, error)
return
let firstResult =
await lightpushPublishHandler(node, pubsubForPublish, msgWithProof, toPeer, mixify)
# Gate the refresh on unambiguously RLN-related failures: 504
# (OUT_OF_RLN_PROOF) is always RLN; 420 (INVALID_MESSAGE) also covers non-RLN
# rejections (e.g. oversized), so additionally require RlnValidatorErrorMsg.
if firstResult.isOk() or rln.isNone():
return firstResult
let isRlnRelatedFailure =
firstResult.error.code == LightPushErrorCode.OUT_OF_RLN_PROOF or (
firstResult.error.code == LightPushErrorCode.INVALID_MESSAGE and
firstResult.error.desc.get("").contains(RlnValidatorErrorMsg)
)
if not isRlnRelatedFailure:
return firstResult
# Schedule the refresh and return immediately, normalized to 504 with
# RlnProofRefreshScheduledMsg so callers can tell "stale proof, retry" from a
# permanent rejection. A retry regenerates against the refreshed cache.
info "lightpush send rejected as RLN-invalid; scheduling merkle proof refresh",
statusCode = $firstResult.error.code
rln.get().groupManager.scheduleMerkleProofRefresh()
return lighpushErrorResult(
LightPushErrorCode.OUT_OF_RLN_PROOF,
RlnProofRefreshScheduledMsg & ": " &
firstResult.error.desc.get($firstResult.error.code),
)

View File

@ -260,4 +260,4 @@ proc setRlnValidator*(
# register rln validator as default validator
info "Registering RLN validator"
node.wakuRelay.addValidator(validator, "RLN validation failed")
node.wakuRelay.addValidator(validator, RlnValidatorErrorMsg)

View File

@ -51,6 +51,49 @@ proc validatePubSubTopics(topics: seq[PubsubTopic]): Result[void, RestApiRespons
return ok()
type
RlnPublishErrorKind = enum
ProofGenFailed ## Local proof generation failed — server-side (500).
ValidationRejected ## Validator rejected the message — client-side (400).
StaleProofSuspected ## Stale merkle path; refresh scheduled — retry (503).
RlnPublishError = object
kind: RlnPublishErrorKind
desc: string
proc attachRlnProofAndValidate(
rln: Rln, wakuRelay: WakuRelay, pubsubTopic: PubsubTopic, message: WakuMessage
): Future[Result[WakuMessage, RlnPublishError]] {.async.} =
## Attaches an RLN proof to `message` and validates it via `wakuRelay`.
## If the validator rejects it as RLN-invalid (error contains
## RlnValidatorErrorMsg), schedules a background merkle proof refresh and
## fails early with StaleProofSuspected — the caller decides whether to
## retry. Callers invoke only when RLN is mounted.
var msg = message
msg.proof = (
await rln.generateRLNProof(msg.toRLNSignal(), float64(getTime().toUnix()))
).valueOr:
return err(
RlnPublishError(
kind: ProofGenFailed, desc: "error appending RLN proof to message: " & $error
)
)
let validateResult = await wakuRelay.validateMessage(pubsubTopic, msg)
if validateResult.isOk():
return ok(msg)
if not validateResult.error.contains(RlnValidatorErrorMsg):
return err(RlnPublishError(kind: ValidationRejected, desc: validateResult.error))
info "relay publish rejected as RLN-invalid; scheduling merkle proof refresh"
rln.groupManager.scheduleMerkleProofRefresh()
return err(
RlnPublishError(
kind: StaleProofSuspected,
desc: RlnProofRefreshScheduledMsg & ": " & validateResult.error,
)
)
proc installRelayApiHandlers*(
router: var RestRouter, node: WakuNode, cache: MessageCache
) =
@ -166,21 +209,20 @@ proc installRelayApiHandlers*(
var message: WakuMessage = reqWakuMessage.toWakuMessage(version = 0).valueOr:
return RestApiResponse.badRequest($error)
# if RLN is mounted, append the proof to the message
if not node.rln.isNil():
# append the proof to the message
message.proof = (
await node.rln.generateRLNProof(
message.toRLNSignal(), float64(getTime().toUnix())
)
message = (
await attachRlnProofAndValidate(node.rln, node.wakuRelay, pubsubTopic, message)
).valueOr:
return RestApiResponse.internalServerError(
"Failed to publish: error appending RLN proof to message: " & $error
)
(await node.wakuRelay.validateMessage(pubsubTopic, message)).isOkOr:
return RestApiResponse.badRequest("Failed to publish: " & error)
case error.kind
of ProofGenFailed:
return RestApiResponse.internalServerError("Failed to publish: " & error.desc)
of ValidationRejected:
return RestApiResponse.badRequest("Failed to publish: " & error.desc)
of StaleProofSuspected:
return RestApiResponse.serviceUnavailable("Failed to publish: " & error.desc)
else:
(await node.wakuRelay.validateMessage(pubsubTopic, message)).isOkOr:
return RestApiResponse.badRequest("Failed to publish: " & error)
# Log for message tracking purposes
logMessageInfo(node.wakuRelay, "rest", pubsubTopic, "none", message, onRecv = true)
@ -297,19 +339,20 @@ proc installRelayApiHandlers*(
error "publish error", err = msg
return RestApiResponse.badRequest("Failed to publish. " & msg)
# if RLN is mounted, append the proof to the message
if not node.rln.isNil():
message.proof = (
await node.rln.generateRLNProof(
message.toRLNSignal(), float64(getTime().toUnix())
)
message = (
await attachRlnProofAndValidate(node.rln, node.wakuRelay, pubsubTopic, message)
).valueOr:
return RestApiResponse.internalServerError(
"Failed to publish: error appending RLN proof to message: " & error
)
(await node.wakuRelay.validateMessage(pubsubTopic, message)).isOkOr:
return RestApiResponse.badRequest("Failed to publish: " & error)
case error.kind
of ProofGenFailed:
return RestApiResponse.internalServerError("Failed to publish: " & error.desc)
of ValidationRejected:
return RestApiResponse.badRequest("Failed to publish: " & error.desc)
of StaleProofSuspected:
return RestApiResponse.serviceUnavailable("Failed to publish: " & error.desc)
else:
(await node.wakuRelay.validateMessage(pubsubTopic, message)).isOkOr:
return RestApiResponse.badRequest("Failed to publish: " & error)
# Log for message tracking purposes
logMessageInfo(node.wakuRelay, "rest", pubsubTopic, "none", message, onRecv = true)

View File

@ -14,14 +14,22 @@ const RlnContractRootCacheSize* = 5
# Using Linea block generation time as reference, which is around 2 seconds
const RootsRefreshMinInterval* = 2.seconds
# Minimum time between two consecutive merkle proof path freshness checks.
# Bounds how often the publish path queries chain when generating proofs at a high rate.
# Using Linea block generation time ~2s and AcceptableRootWindowSize=50, we give a generous safety margin within this
const PathCheckMinInterval* = 30.seconds
# RLN membership key and index files path
const RlnCredentialsFilename* = "rlnCredentials.txt"
# RLN Validator message rejection Error string, is used to trigger proof refresh and publish retry in the lightpush client
const RlnValidatorErrorMsg* = "RLN validation failed"
# OUT_OF_RLN_PROOF description marker telling callers a background refresh was
# scheduled and retrying the publish is worthwhile.
const RlnProofRefreshScheduledMsg* =
"stale RLN proof suspected; refresh scheduled, retry the publish"
# Bounds the legacy lightpush merkle proof refresh (eth_call refetch + proof
# regen) so a hanging RPC cannot stall the caller. The retried publish is not
# bounded.
const RlnMerkleProofRefreshTimeout* = 5.seconds
# inputs of the membership contract constructor
# TODO may be able to make these constants private and put them inside the waku_rln_utils
const

View File

@ -141,5 +141,15 @@ method generateProof*(
## Dummy implementation for generateProof
return err("generateProof is not implemented")
method invalidateMerkleProofCache*(g: GroupManager) {.base, gcsafe, raises: [].} =
## Drops the cached merkle proof path so the next proof-gen refetches from
## chain. Called after a publish is rejected on a stale cache. No-op base.
discard
method scheduleMerkleProofRefresh*(g: GroupManager) {.base, gcsafe, raises: [].} =
## Like `invalidateMerkleProofCache`, but starts the refetch in the
## background so the caller need not wait for it. No-op base.
discard
method isReady*(g: GroupManager): Future[bool] {.base, async.} =
return true

View File

@ -44,8 +44,8 @@ type
registrationHandler*: Option[RegistrationHandler]
latestProcessedBlock*: BlockNumber
merkleProofCache*: seq[byte]
lastMerklePathCheckMoment*: Moment
proofPathRefreshInFlightFut*: Future[void]
merkleProofCacheGeneration: uint64
proofPathRefreshInFlightFut*: Future[seq[byte]]
lastRootsRefreshMoment*: Moment
rootsRefreshInFlightFut*: Future[void]
@ -236,12 +236,13 @@ proc updateMemberCount*(
proc refreshRoots(g: OnchainGroupManager): Future[void] {.async.} =
## On-demand refresh of validRoots from the on-chain root cache.
## Throttled to at most one refresh per RootsRefreshMinInterval; concurrent
## callers outside the throttle window coalesce onto a single in-flight refresh.
## callers outside the throttle window coalesce onto a single in-flight
## refresh, awaited via `join` so cancelling one caller never cancels it.
if Moment.now() - g.lastRootsRefreshMoment < RootsRefreshMinInterval:
return
if not g.rootsRefreshInFlightFut.isNil() and not g.rootsRefreshInFlightFut.finished():
await g.rootsRefreshInFlightFut
await g.rootsRefreshInFlightFut.join()
return
proc doRefresh(): Future[void] {.async.} =
@ -251,7 +252,7 @@ proc refreshRoots(g: OnchainGroupManager): Future[void] {.async.} =
g.lastRootsRefreshMoment = Moment.now()
g.rootsRefreshInFlightFut = doRefresh()
await g.rootsRefreshInFlightFut
await g.rootsRefreshInFlightFut.join()
method validateRoot*(g: OnchainGroupManager, root: MerkleNode): Future[bool] {.async.} =
if g.indexOfRoot(root) >= 0:
@ -260,45 +261,73 @@ method validateRoot*(g: OnchainGroupManager, root: MerkleNode): Future[bool] {.a
await g.refreshRoots()
return g.indexOfRoot(root) >= 0
# Bounds refetches when invalidations keep landing mid-fetch, so a rejection
# storm cannot pin the refresh loop to the eth client.
const MerkleProofRefetchMaxAttempts = 3
proc ensureFreshMerkleProofPath*(
g: OnchainGroupManager
): Future[Result[void, string]] {.async.} =
## Keeps `merkleProofCache` fresh independently of the validRoots window
## used by the receive path. Refetches the path whenever the throttle
## (`PathCheckMinInterval`) expires; trusts the cached path otherwise.
## Guards against a missing membership index because `fetchMerkleProofElements`
## unwraps it.
): Future[Result[seq[byte], string]] {.async.} =
## Returns the merkle proof path, refetching from chain when the cache is
## empty; suspected-stale callers invalidate first. Concurrent callers
## coalesce onto one refetch, awaited via `join` so cancelling one caller
## never cancels the shared refetch. Callers use the returned path, not the
## cache field, so a concurrent invalidate cannot fail an in-flight proof-gen.
if g.membershipIndex.isNone():
return err("membership index is not set")
if g.merkleProofCache.len > 0 and
Moment.now() - g.lastMerklePathCheckMoment < PathCheckMinInterval:
return ok()
if g.merkleProofCache.len > 0:
return ok(g.merkleProofCache)
if not g.proofPathRefreshInFlightFut.isNil() and
not g.proofPathRefreshInFlightFut.finished():
await g.proofPathRefreshInFlightFut
if g.merkleProofCache.len > 0:
return ok()
proc doRefresh(): Future[seq[byte]] {.async.} =
var pathBytes: seq[byte]
for _ in 0 ..< MerkleProofRefetchMaxAttempts:
let generation = g.merkleProofCacheGeneration
pathBytes = (await g.fetchMerkleProofElements()).valueOr:
error "Failed to refresh merkle proof path", error = error
return @[]
if g.merkleProofCacheGeneration == generation:
g.merkleProofCache = pathBytes
break
# An invalidate raced this fetch, so the fetched path may predate the
# change that triggered it: fetch again. If attempts run out the last
# path is returned uncached and the next publish refetches.
if pathBytes.len > 0:
# Best-effort metric refresh - if there's a failure, it will update with the next root change
discard await g.updateMemberCount()
return pathBytes
if g.proofPathRefreshInFlightFut.isNil() or g.proofPathRefreshInFlightFut.finished():
g.proofPathRefreshInFlightFut = doRefresh()
let refreshFut = g.proofPathRefreshInFlightFut
await refreshFut.join()
if not refreshFut.completed():
return err("merkle proof path refresh failed")
var fetchOk = false
proc doRefresh(): Future[void] {.async.} =
let pathBytes = (await g.fetchMerkleProofElements()).valueOr:
error "Failed to refresh merkle proof path", error = error
return
g.merkleProofCache = pathBytes
g.lastMerklePathCheckMoment = Moment.now()
fetchOk = true
# Best-effort metric refresh - if there's a failure, it will update with the next root change
discard await g.updateMemberCount()
g.proofPathRefreshInFlightFut = doRefresh()
await g.proofPathRefreshInFlightFut
if not fetchOk:
let pathBytes = refreshFut.read()
if pathBytes.len == 0:
return err("merkle proof path refresh failed")
return ok()
return ok(pathBytes)
method invalidateMerkleProofCache*(g: OnchainGroupManager) {.gcsafe, raises: [].} =
## Empties the cache so the next `ensureFreshMerkleProofPath` refetches. The
## generation bump makes a refetch already in flight fetch again rather than
## serve a path that may predate this invalidate.
g.merkleProofCache = @[]
g.merkleProofCacheGeneration.inc()
method scheduleMerkleProofRefresh*(g: OnchainGroupManager) {.gcsafe, raises: [].} =
## Invalidates the cache and spawns a detached refetch; a failed refetch is
## logged and repaired by the next `ensureFreshMerkleProofPath`.
g.invalidateMerkleProofCache()
proc refresh() {.async.} =
let res = await g.ensureFreshMerkleProofPath()
if res.isErr():
warn "merkle proof refresh failed", error = res.error
asyncSpawn refresh()
method register*(
g: OnchainGroupManager, rateCommitment: RateCommitment
@ -485,9 +514,9 @@ method generateProof*(
return err("user message limit is not set")
debug "Generating RLN proof"
?(await g.ensureFreshMerkleProofPath())
let merkleProofPath = ?(await g.ensureFreshMerkleProofPath())
if (g.merkleProofCache.len mod 32) != 0:
if (merkleProofPath.len mod 32) != 0:
return err("Invalid merkle proof cache length")
let identity_secret = seqToField(g.idCredentials.get().idSecretHash)
@ -496,8 +525,8 @@ method generateProof*(
var path_elements = newSeq[byte](0)
let identity_path_index = uint64ToIndex(g.membershipIndex.get(), 20)
for i in 0 ..< g.merkleProofCache.len div 32:
let chunk = g.merkleProofCache[i * 32 .. (i + 1) * 32 - 1]
for i in 0 ..< merkleProofPath.len div 32:
let chunk = merkleProofPath[i * 32 .. (i + 1) * 32 - 1]
path_elements.add(chunk.reversed())
let xCfr = hashToFieldLe(data).valueOr:

View File

@ -64,9 +64,43 @@ proc generateRLNProof*(
return err("could not generate rln-v2 proof: " & $error)
return ok(proof.encode().buffer)
proc generateRLNProofWithRootRefresh*(
rln: Rln, input: seq[byte], senderEpochTime: float64
): Future[Result[seq[byte], string]] {.async.} =
## Generates an RLN proof and checks its merkle root against the
## acceptable-root window. If the root is stale, invalidates the cache and
## regenerates once against a refetched path. Returns the proof bytes.
let proofBytes = (await rln.generateRLNProof(input, senderEpochTime)).valueOr:
return err("failed to generate RLN proof: " & $error)
let rlnProof = RateLimitProof.init(proofBytes).valueOr:
return err("could not decode proof for root check: " & $error)
if await rln.groupManager.validateRoot(rlnProof.merkleRoot):
return ok(proofBytes)
info "RLN: stale merkle root detected; refreshing merkle path and regenerating proof"
rln.groupManager.invalidateMerkleProofCache()
return await rln.generateRLNProof(input, senderEpochTime)
proc attachRLNProof*(
r: Rln, message: WakuMessage
): Future[Result[WakuMessage, string]] {.async.} =
## Returns the message with a freshly generated RLN proof, replacing any
## existing one and drawing a new message id. Retry paths suspecting a stale
## path should call `invalidateMerkleProofCache` first.
var msgWithProof = message
msgWithProof.proof = (
await r.generateRLNProof(message.toRLNSignal(), float64(getTime().toUnix()))
).valueOr:
return err("error in attachRLNProof: " & error)
return ok(msgWithProof)
proc checkAndGenerateRLNProof*(
rln: Option[Rln], message: WakuMessage
): Future[Result[WakuMessage, string]] {.async.} =
## Returns the message with an attached RLN proof, or unchanged when it
## already carries a proof or RLN is not configured.
if message.proof.len > 0:
return ok(message)
@ -74,12 +108,4 @@ proc checkAndGenerateRLNProof*(
notice "Publishing message without RLN proof"
return ok(message)
let
time = getTime().toUnix()
senderEpochTime = float64(time)
var msgWithProof = message
msgWithProof.proof = (
await rln.get().generateRLNProof(msgWithProof.toRLNSignal(), senderEpochTime)
).valueOr:
return err("error in checkAndGenerateRLNProof: " & $error)
return ok(msgWithProof)
return await attachRLNProof(rln.get(), message)

View File

@ -233,10 +233,11 @@ proc mount(
proc(
msg: WakuMessage, senderEpochTime: float64
): Future[Result[RequestGenerateRlnProof, string]] {.async.} =
let proof = (await rln.generateRLNProof(msg.toRLNSignal(), senderEpochTime)).valueOr:
let proofBytes = (
await rln.generateRLNProofWithRootRefresh(msg.toRLNSignal(), senderEpochTime)
).valueOr:
return err("Could not create RLN proof: " & error)
return ok(RequestGenerateRlnProof(proof: proof)),
return ok(RequestGenerateRlnProof(proof: proofBytes)),
).isOkOr:
return err("Proof generator provider cannot be set: " & $error)

View File

@ -1,7 +1,7 @@
{.used.}
import
std/[options, tempfiles, net, osproc],
std/[options, tempfiles, net, osproc, strutils],
testutils/unittests,
chronos,
std/strformat,
@ -16,6 +16,7 @@ import
waku_lightpush_legacy/common,
waku_lightpush_legacy/protocol_metrics,
rln,
rln/constants,
],
../testlib/[wakucore, wakunode, testasync, futures, testutils],
../resources/payloads,
@ -120,7 +121,10 @@ suite "RLN Proofs as a Lightpush Service":
# mount rln-relay
# match prod epoch window to reduce test flake
wakuRlnConfig = getWakuRlnConfig(
manager = manager, index = MembershipIndex(1), epochSizeSec = 600
manager = manager,
userMessageLimit = 20,
index = MembershipIndex(1),
epochSizeSec = 600,
)
await allFutures(server.start(), client.start())
@ -180,6 +184,95 @@ suite "RLN Proofs as a Lightpush Service":
assert publishResponse.isErr(), "We expect an error response"
check publishResponse.error == protocol_metrics.notPublishedAnyPeer
# The tests below drive `server.legacyLightpushPublish(...)` against the
# server node. Because `server.wakuLegacyLightPush` is mounted (and no
# legacy client is), the call takes the self-request path — it still runs
# the full client-side flow (proof gen, retry on RlnValidatorErrorMsg
# substring, one-retry cap), but the request lands in the local
# pushHandler. Swapping in a stub pushHandler lets each test control what
# attempt N sees.
asyncTest "retry fires on RlnValidatorErrorMsg substring and second attempt succeeds":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult[void]] {.async.} =
inc callCount
if callCount == 1:
return err(RlnValidatorErrorMsg & ": simulated stale merkle path")
return ok()
server.wakuLegacyLightPush.pushHandler = stub
let response = await server.legacyLightpushPublish(
some(pubsubTopic), message, server.peerInfo.toRemotePeerInfo()
)
check:
callCount == 2
response.isOk()
asyncTest "no retry when error does not contain RlnValidatorErrorMsg":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult[void]] {.async.} =
inc callCount
return err("unrelated failure")
server.wakuLegacyLightPush.pushHandler = stub
let response = await server.legacyLightpushPublish(
some(pubsubTopic), message, server.peerInfo.toRemotePeerInfo()
)
check:
callCount == 1
response.isErr()
response.error == "unrelated failure"
asyncTest "retry cap: two consecutive RLN errors surface the second":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult[void]] {.async.} =
inc callCount
return err(RlnValidatorErrorMsg & ": still stale")
server.wakuLegacyLightPush.pushHandler = stub
let response = await server.legacyLightpushPublish(
some(pubsubTopic), message, server.peerInfo.toRemotePeerInfo()
)
check:
callCount == 2
response.isErr()
response.error.contains(RlnValidatorErrorMsg)
asyncTest "no retry when node.rln is nil":
# Detach RLN so the retry branch short-circuits on rln.isNone() even
# when the error string carries RlnValidatorErrorMsg. Restore before
# teardown so server.stop() sees the same object graph it was
# constructed with.
let savedRln = server.rln
server.rln = nil
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult[void]] {.async.} =
inc callCount
return err(RlnValidatorErrorMsg & ": simulated")
server.wakuLegacyLightPush.pushHandler = stub
let response = await server.legacyLightpushPublish(
some(pubsubTopic), message, server.peerInfo.toRemotePeerInfo()
)
server.rln = savedRln
check:
callCount == 1
response.isErr()
suite "Waku Legacy Lightpush message delivery":
asyncTest "Legacy lightpush message flow succeed":
## Setup

View File

@ -1,7 +1,7 @@
{.used.}
import
std/[options, tempfiles, osproc],
std/[options, tempfiles, osproc, strutils],
testutils/unittests,
chronos,
std/strformat,
@ -117,7 +117,10 @@ suite "RLN Proofs as a Lightpush Service":
# mount rln-relay
# match prod epoch window to reduce test flake
wakuRlnConfig = getWakuRlnConfig(
manager = manager, index = MembershipIndex(1), epochSizeSec = 600
manager = manager,
userMessageLimit = 20,
index = MembershipIndex(1),
epochSizeSec = 600,
)
await allFutures(server.start(), client.start())
@ -177,6 +180,182 @@ suite "RLN Proofs as a Lightpush Service":
assert publishResponse.isErr(), "We expect an error response"
check publishResponse.error.code == LightPushErrorCode.NO_PEERS_TO_RELAY
asyncTest "invalidate + regenerate refetches merkle path and rebuilds proof":
# Exercises the primitive pair that lightpushPublish leans on after a
# 420 (INVALID_MESSAGE) or 504 (OUT_OF_RLN_PROOF) rejection: calling
# invalidateMerkleProofCache empties the cached path so the next
# proof-gen refetches from chain, and attachRLNProof rebuilds the proof
# even though the message already carries one.
let firstMsg = (await checkAndGenerateRLNProof(some(server.rln), message)).get()
check firstMsg.proof.len > 0
# Corrupt the cache to model a stale/invalid witness — the same state a
# 420/504 rejection would leave us in.
let manager = cast[OnchainGroupManager](server.rln.groupManager)
let goodCache = manager.merkleProofCache
manager.merkleProofCache = newSeq[byte](goodCache.len)
check manager.merkleProofCache != goodCache
# Retry path: invalidate the cache so the next proof-gen refetches from
# chain, then regenerate the proof.
manager.invalidateMerkleProofCache()
let secondMsg = (await attachRLNProof(server.rln, firstMsg)).get()
check:
secondMsg.proof.len > 0
# Regenerated, not passed through — Groth16 proofs carry random
# blinding, so a fresh call produces different bytes.
secondMsg.proof != firstMsg.proof
# Cache was refetched from chain, overwriting the corruption.
manager.merkleProofCache == goodCache
# The tests below drive `server.lightpushPublish(...)` directly against the
# server node. Because `server.wakuLightPush` is mounted (and no lightpush
# client is), lightpushPublish takes the self-request path — it still runs
# the full client-side flow (proof gen, RLN-rejection classification), but
# the request lands in the local pushHandler instead of going over the
# wire. Swapping in a stub pushHandler lets each test control exactly what
# each attempt sees.
asyncTest "RLN-related 420 surfaces as 504 and schedules a merkle proof refresh":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult] {.async.} =
inc callCount
return
lighpushErrorResult(LightPushErrorCode.INVALID_MESSAGE, RlnValidatorErrorMsg)
server.wakuLightPush.pushHandler = stub
let manager = cast[OnchainGroupManager](server.rln.groupManager)
let goodCache = manager.merkleProofCache
check goodCache.len > 0
let response = await server.lightpushPublish(some(pubsubTopic), message)
check:
# The rejection is surfaced immediately — no internal republish — and
# normalized to 504 so callers can key their retry on it.
callCount == 1
response.isErr()
response.error.code == LightPushErrorCode.OUT_OF_RLN_PROOF
response.error.desc.get("").contains(RlnProofRefreshScheduledMsg)
# The refresh runs detached from the publish call: the cache was
# dropped and a refetch is in flight. Once it settles the path is
# fresh again.
let inFlight = manager.proofPathRefreshInFlightFut
if not inFlight.isNil():
await inFlight.join()
check manager.merkleProofCache == goodCache
asyncTest "504 (OUT_OF_RLN_PROOF) from the service passes through without republish":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult] {.async.} =
inc callCount
return lighpushErrorResult(
LightPushErrorCode.OUT_OF_RLN_PROOF, "simulated out-of-proof"
)
server.wakuLightPush.pushHandler = stub
let response = await server.lightpushPublish(some(pubsubTopic), message)
check:
callCount == 1
response.isErr()
response.error.code == LightPushErrorCode.OUT_OF_RLN_PROOF
asyncTest "caller retry after 504 lands on the refreshed merkle path":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult] {.async.} =
inc callCount
if callCount == 1:
return lighpushErrorResult(
LightPushErrorCode.INVALID_MESSAGE, RlnValidatorErrorMsg
)
return lightpushSuccessResult(1)
server.wakuLightPush.pushHandler = stub
let firstResponse = await server.lightpushPublish(some(pubsubTopic), message)
check:
firstResponse.isErr()
firstResponse.error.code == LightPushErrorCode.OUT_OF_RLN_PROOF
# Model the caller-level retry (send service next round, REST handler):
# a second publish regenerates the proof against the refreshed cache.
let secondResponse = await server.lightpushPublish(some(pubsubTopic), message)
check:
callCount == 2
secondResponse.isOk()
secondResponse.get() == 1
asyncTest "420 without the RLN error text passes through unchanged":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult] {.async.} =
inc callCount
return
lighpushErrorResult(LightPushErrorCode.INVALID_MESSAGE, "unrelated rejection")
server.wakuLightPush.pushHandler = stub
let response = await server.lightpushPublish(some(pubsubTopic), message)
check:
callCount == 1
response.isErr()
response.error.code == LightPushErrorCode.INVALID_MESSAGE
asyncTest "error codes outside 420/504 pass through unchanged":
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult] {.async.} =
inc callCount
return lighpushErrorResult(
LightPushErrorCode.INTERNAL_SERVER_ERROR, "unrelated failure"
)
server.wakuLightPush.pushHandler = stub
let response = await server.lightpushPublish(some(pubsubTopic), message)
check:
callCount == 1
response.isErr()
response.error.code == LightPushErrorCode.INTERNAL_SERVER_ERROR
asyncTest "rejection passes through unchanged when node.rln is nil":
# Detach RLN so the RLN-rejection branch short-circuits on rln.isNone()
# even for a 420. Restore before teardown so server.stop() sees the same
# object graph it was constructed with.
let savedRln = server.rln
server.rln = nil
var callCount = 0
let stub: PushMessageHandler = proc(
pubsubTopic: PubsubTopic, message: WakuMessage
): Future[WakuLightPushResult] {.async.} =
inc callCount
return lighpushErrorResult(
LightPushErrorCode.INVALID_MESSAGE, "simulated stale merkle path"
)
server.wakuLightPush.pushHandler = stub
let response = await server.lightpushPublish(some(pubsubTopic), message)
server.rln = savedRln
check:
callCount == 1
response.isErr()
response.error.code == LightPushErrorCode.INVALID_MESSAGE
suite "Waku Lightpush message delivery":
asyncTest "lightpush message flow succeed":
## Setup

View File

@ -303,9 +303,6 @@ suite "Onchain group manager":
# chunk[0] becomes the MSB after reversal in group_manager; must be < 0x30
for i in 0 ..< 20:
manager.merkleProofCache[i * 32] = 0
# Pin the freshness throttle so ensureFreshMerkleProofPath does NOT refetch
# and overwrite the intentionally-corrupted cache we just planted.
manager.lastMerklePathCheckMoment = Moment.now()
let messageBytes = "Hello".toBytes()
@ -435,7 +432,7 @@ suite "Onchain group manager":
# replaced by a competing refresh.
manager.rootsRefreshInFlightFut == inFlight
test "generateProof: fast-paths without refresh inside throttle window":
test "validateRoot: cancelling one caller does not cancel the shared roots refresh":
(waitFor manager.init()).isOkOr:
raiseAssert $error
@ -443,11 +440,47 @@ suite "Onchain group manager":
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
# Prime cache and pin the throttle so the publish-path freshness check
# short-circuits on the cached value.
manager.lastRootsRefreshMoment = default(Moment)
manager.rootsRefreshInFlightFut = nil
var badRoot: MerkleNode
badRoot[0] = 0x66
let f1 = manager.validateRoot(badRoot)
let inFlight = manager.rootsRefreshInFlightFut
let f2 = manager.validateRoot(badRoot)
check:
inFlight != nil
not inFlight.finished()
# Cancel the initiating caller; the shared refresh must keep running for
# the coalesced one.
waitFor f1.cancelAndWait()
check:
f1.cancelled()
not inFlight.cancelled()
discard waitFor f2
check:
inFlight.completed()
manager.rootsRefreshInFlightFut == inFlight
test "generateProof: fast-paths without refresh when cache is populated":
(waitFor manager.init()).isOkOr:
raiseAssert $error
let credentials = generateCredentials()
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
# Prime cache. A non-empty cache short-circuits ensureFreshMerkleProofPath;
# the retry path invalidates the cache via invalidateMerkleProofCache before
# calling proof-gen again.
manager.merkleProofCache = (waitFor manager.fetchMerkleProofElements()).valueOr:
raiseAssert "failed to fetch initial path: " & error
manager.lastMerklePathCheckMoment = Moment.now()
manager.proofPathRefreshInFlightFut = nil
let primedCache = manager.merkleProofCache
@ -498,7 +531,7 @@ suite "Onchain group manager":
res.isErr()
res.error == "membership index is not set"
test "ensureFreshMerkleProofPath: refetches when throttle window has expired":
test "invalidateMerkleProofCache + ensureFreshMerkleProofPath refetches":
(waitFor manager.init()).isOkOr:
raiseAssert $error
@ -506,22 +539,21 @@ suite "Onchain group manager":
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
# Prime cache with a non-empty value and an old throttle timestamp, so
# the cache fast-path does NOT trigger and we exercise the refetch branch.
# Prime cache with a non-empty value, then take the retry path:
# invalidate empties the cache so ensureFreshMerkleProofPath refetches.
manager.merkleProofCache = (waitFor manager.fetchMerkleProofElements()).valueOr:
raiseAssert "failed to prime path: " & error
manager.lastMerklePathCheckMoment = Moment.now() - PathCheckMinInterval - 1.seconds
manager.proofPathRefreshInFlightFut = nil
let preCheckTs = manager.lastMerklePathCheckMoment
manager.invalidateMerkleProofCache()
check manager.merkleProofCache.len == 0
let res = waitFor manager.ensureFreshMerkleProofPath()
check:
res.isOk()
manager.merkleProofCache.len > 0
manager.proofPathRefreshInFlightFut != nil
# lastMerklePathCheckMoment was bumped to "now" by the refetch.
manager.lastMerklePathCheckMoment > preCheckTs
test "ensureFreshMerkleProofPath: refresh bumps the member-count metric":
(waitFor manager.init()).isOkOr:
@ -554,12 +586,10 @@ suite "Onchain group manager":
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
# Empty cache + epoch-zero check timestamp guarantees the first caller
# will fall through to fetchMerkleProofElements; the followers should
# observe the resulting in-flight future and await it rather than start
# their own refresh.
# Empty cache guarantees the first caller will fall through to
# fetchMerkleProofElements; the followers should observe the resulting
# in-flight future and await it rather than start their own refresh.
manager.merkleProofCache = @[]
manager.lastMerklePathCheckMoment = default(Moment)
manager.proofPathRefreshInFlightFut = nil
let f1 = manager.ensureFreshMerkleProofPath()
@ -589,6 +619,130 @@ suite "Onchain group manager":
manager.proofPathRefreshInFlightFut == inFlight
manager.merkleProofCache.len > 0
test "ensureFreshMerkleProofPath: cancelling one caller does not cancel the shared refetch":
(waitFor manager.init()).isOkOr:
raiseAssert $error
let credentials = generateCredentials()
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
manager.merkleProofCache = @[]
manager.proofPathRefreshInFlightFut = nil
let f1 = manager.ensureFreshMerkleProofPath()
let inFlight = manager.proofPathRefreshInFlightFut
let f2 = manager.ensureFreshMerkleProofPath()
check:
inFlight != nil
not inFlight.finished()
# Cancel the initiating caller — models a publish retry whose refresh
# timeout fired while the refetch was still in flight.
waitFor f1.cancelAndWait()
check:
f1.cancelled()
# The shared refetch survives the caller's cancellation.
not inFlight.cancelled()
# The coalesced caller still completes and the cache gets populated.
let r2 = waitFor f2
check:
r2.isOk()
manager.merkleProofCache.len > 0
manager.proofPathRefreshInFlightFut == inFlight
test "scheduleMerkleProofRefresh: drops the cache and refetches in the background":
(waitFor manager.init()).isOkOr:
raiseAssert $error
let credentials = generateCredentials()
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
# Prime the cache, then corrupt it to model the stale path a publish
# rejection points at.
let goodCache = (waitFor manager.fetchMerkleProofElements()).valueOr:
raiseAssert "failed to prime path: " & error
manager.merkleProofCache = newSeq[byte](goodCache.len)
manager.proofPathRefreshInFlightFut = nil
manager.scheduleMerkleProofRefresh()
# The call returns without waiting; the refetch is already in flight.
let inFlight = manager.proofPathRefreshInFlightFut
check inFlight != nil
waitFor inFlight.join()
check manager.merkleProofCache == goodCache
test "ensureFreshMerkleProofPath: invalidate during the refetch does not fail callers":
(waitFor manager.init()).isOkOr:
raiseAssert $error
let credentials = generateCredentials()
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
manager.merkleProofCache = @[]
manager.proofPathRefreshInFlightFut = nil
# Both callers coalesce onto a refetch suspended on the eth call.
let f1 = manager.ensureFreshMerkleProofPath()
let inFlight = manager.proofPathRefreshInFlightFut
let f2 = manager.ensureFreshMerkleProofPath()
check:
inFlight != nil
not inFlight.finished()
# A publish rejection lands mid-fetch. The invalidate must neither fail
# the coalesced callers nor be swallowed by the fetch it raced: the path
# they receive is refetched after the invalidate.
manager.invalidateMerkleProofCache()
check manager.merkleProofCache.len == 0
let r1 = waitFor f1
let r2 = waitFor f2
check:
r1.isOk()
r1.get().len > 0
r2.isOk()
r2.get() == r1.get()
# The post-invalidate refetch repopulated the cache.
manager.merkleProofCache == r1.get()
test "generateProof: succeeds when the cache is invalidated mid-refetch":
(waitFor manager.init()).isOkOr:
raiseAssert $error
let credentials = generateCredentials()
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
manager.merkleProofCache = @[]
manager.proofPathRefreshInFlightFut = nil
# generateProof runs down to the refetch's suspended eth call.
let proofFut = manager.generateProof(
data = "hello".toBytes(), epoch = default(Epoch), messageId = MessageId(1)
)
check:
manager.proofPathRefreshInFlightFut != nil
not manager.proofPathRefreshInFlightFut.finished()
# A concurrent publish rejection empties the cache while proof-gen waits
# on the refetch.
manager.invalidateMerkleProofCache()
let proofRes = waitFor proofFut
check:
proofRes.isOk()
test "verifyProof: should verify valid proof":
let credentials = generateCredentials()
(waitFor manager.init()).isOkOr:
@ -657,9 +811,6 @@ suite "Onchain group manager":
# chunk[0] becomes the MSB after reversal in group_manager; must be < 0x30
for i in 0 ..< 20:
manager.merkleProofCache[i * 32] = 0
# Pin the freshness throttle so ensureFreshMerkleProofPath does NOT refetch
# and overwrite the intentionally-corrupted cache we just planted.
manager.lastMerklePathCheckMoment = Moment.now()
let epoch = default(Epoch)
info "epoch in bytes", epochHex = epoch.inHex()

View File

@ -11,7 +11,8 @@ import
brokers/broker_context
import
logos_delivery/waku/[waku_core, waku_node, rln],
logos_delivery/waku/[waku_core, waku_node, rln, rln/protocol_types],
logos_delivery/waku/requests/rln_requests,
../testlib/[wakucore, futures, wakunode, testutils],
./utils_onchain,
./rln/waku_rln_relay_utils
@ -751,3 +752,55 @@ procSuite "WakuNode - RLN relay":
# Cleanup
waitFor allFutures(node1.stop(), node2.stop())
asyncTest "broker proof provider retries with force-refresh when initial proof has stale root":
## Exercises the reactive mechanism added to RequestGenerateRlnProof.setProvider
## in rln.nim: when the cached Merkle proof path produces a proof with a root
## that validateRoot rejects, the provider must force-refresh the path and
## return a proof whose root is in the valid-roots window.
lockNewGlobalBrokerContext:
let nodeKey = generateSecp256k1Key()
let node = newTestWakuNode(nodeKey)
(await node.mountRelay()).isOkOr:
assert false, "Failed to mount relay"
let wakuRlnConfig = getWakuRlnConfig(
manager = manager,
index = MembershipIndex(1),
epochSizeSec = 600,
userMessageLimit = 20,
)
await node.setRlnValidator(wakuRlnConfig)
await node.start()
let rlnManager = cast[OnchainGroupManager](node.rln.groupManager)
let idCredentials = generateCredentials()
(waitFor rlnManager.register(idCredentials, UserMessageLimit(20))).isOkOr:
assert false, "Failed to register: " & error
let rootUpdated = waitFor rlnManager.updateRoots()
info "Updated root", rootUpdated
let proofRes = waitFor rlnManager.fetchMerkleProofElements()
assert proofRes.isOk(), "failed to fetch merkle proof: " & proofRes.error
let goodCache = proofRes.get()
rlnManager.merkleProofCache = goodCache
# Corrupt the cache so the first generateRLNProof inside the provider
# produces a proof with a Merkle root that is not in the valid-roots window.
# The provider must detect this via validateRoot, force-refresh, and retry.
rlnManager.merkleProofCache = newSeq[byte](goodCache.len)
let msg = fakeWakuMessage()
let proofResult =
await RequestGenerateRlnProof.request(node.rln.brokerCtx, msg, epochTime())
check proofResult.isOk()
# The force-refresh inside the provider restored the correct path
check rlnManager.merkleProofCache == goodCache
# The returned proof carries a Merkle root that is in the valid-roots window
let rlnProof = RateLimitProof.init(proofResult.get().proof).get()
let rootValid = await node.rln.groupManager.validateRoot(rlnProof.merkleRoot)
check rootValid
await node.stop()

View File

@ -1,7 +1,7 @@
{.used.}
import
std/[sequtils, strformat, tempfiles, osproc, options],
std/[sequtils, strformat, strutils, tempfiles, osproc, options],
stew/byteutils,
testutils/unittests,
presto,
@ -793,3 +793,199 @@ suite "Waku v2 Rest API - Relay":
await restServer.stop()
await restServer.closeWait()
await node.stop()
asyncTest "Stale RLN proof returns 503 and schedules a refresh - POST /relay/v1/messages/{topic}":
## When the cached Merkle proof path is stale the handler generates a proof
## whose root the local RLN validator rejects. The handler must detect the
## RlnValidatorErrorMsg, schedule a background merkle proof refresh, and
## fail early with 503 + RlnProofRefreshScheduledMsg. A client retry then
## succeeds against the refreshed path.
let node = testWakuNode()
(await node.mountRelay()).isOkOr:
assert false, "Failed to mount relay"
let wakuRlnConfig = getWakuRlnConfig(
manager = manager,
index = MembershipIndex(1),
epochSizeSec = 600,
userMessageLimit = 20,
)
await node.setRlnValidator(wakuRlnConfig)
await node.start()
let manager = cast[OnchainGroupManager](node.rln.groupManager)
let idCredentials = generateCredentials()
(waitFor manager.register(idCredentials, UserMessageLimit(20))).isOkOr:
assert false, "Failed to register: " & getCurrentExceptionMsg()
let rootUpdated = waitFor manager.updateRoots()
info "Updated root", rootUpdated
let proofRes = waitFor manager.fetchMerkleProofElements()
assert proofRes.isOk(), "failed to fetch merkle proof: " & proofRes.error
let goodCache = proofRes.get()
manager.merkleProofCache = goodCache
# Corrupt the cache with zeros so the first generateRLNProof call produces a
# proof with a Merkle root that is not in the valid-roots window.
# validateMessage will return RlnValidatorErrorMsg.
manager.merkleProofCache = newSeq[byte](goodCache.len)
var restPort = Port(0)
let restAddress = parseIpAddress("0.0.0.0")
let restServer = WakuRestServerRef.init(restAddress, restPort).tryGet()
restPort = restServer.httpServer.address.port
let cache = MessageCache.init()
installRelayApiHandlers(restServer.router, node, cache)
restServer.start()
let client = newRestHttpClient(initTAddress(restAddress, restPort))
let simpleHandler = proc(
topic: PubsubTopic, msg: WakuMessage
): Future[void] {.async, gcsafe.} =
await sleepAsync(0.milliseconds)
node.subscribe((kind: PubsubSub, topic: DefaultPubsubTopic), simpleHandler).isOkOr:
assert false, "Failed to subscribe to pubsub topic"
let response = await client.relayPostMessagesV1(
DefaultPubsubTopic,
RelayWakuMessage(
payload: base64.encode("TEST-PAYLOAD"),
contentTopic: some(DefaultContentTopic),
timestamp: some(now()),
),
)
# The handler fails early with the retry signal; the refresh runs detached.
check:
response.status == 503
$response.contentType == $MIMETYPE_TEXT
response.data.contains(RlnProofRefreshScheduledMsg)
let inFlight = manager.proofPathRefreshInFlightFut
if not inFlight.isNil():
await inFlight.join()
check manager.merkleProofCache == goodCache # refresh restored the correct path
# A client retry now succeeds against the refreshed path.
let retryResponse = await client.relayPostMessagesV1(
DefaultPubsubTopic,
RelayWakuMessage(
payload: base64.encode("TEST-PAYLOAD"),
contentTopic: some(DefaultContentTopic),
timestamp: some(now()),
),
)
check:
retryResponse.status == 200
retryResponse.data == "OK"
await restServer.stop()
await restServer.closeWait()
await node.stop()
asyncTest "Stale RLN proof returns 503 and schedules a refresh - POST /relay/v1/auto/messages/{topic}":
## Same fail-fast behavior as the static-sharding handler, exercised via
## the auto-sharding endpoint. A relay-only mesh node is connected so that
## node.publish() has a gossipsub peer and the client retry can return
## success.
# Relay-only mesh node — no RLN needed, just provides a gossipsub peer.
let meshNode = testWakuNode()
(await meshNode.mountRelay()).isOkOr:
assert false, "Failed to mount relay on mesh node"
require meshNode.mountAutoSharding(1, 8).isOk
await meshNode.start()
let meshHandler = proc(
topic: PubsubTopic, msg: WakuMessage
): Future[void] {.async, gcsafe.} =
discard
meshNode.subscribe((kind: ContentSub, topic: DefaultContentTopic), meshHandler).isOkOr:
assert false, "Failed to subscribe mesh node"
var node: WakuNode
lockNewGlobalBrokerContext:
node = testWakuNode()
(await node.mountRelay()).isOkOr:
assert false, "Failed to mount relay"
require node.mountAutoSharding(1, 8).isOk
let wakuRlnConfig = getWakuRlnConfig(
manager = manager,
index = MembershipIndex(1),
epochSizeSec = 600,
userMessageLimit = 20,
)
await node.setRlnValidator(wakuRlnConfig)
await node.start()
await node.connectToNodes(@[meshNode.peerInfo.toRemotePeerInfo()])
let manager = cast[OnchainGroupManager](node.rln.groupManager)
let idCredentials = generateCredentials()
(waitFor manager.register(idCredentials, UserMessageLimit(20))).isOkOr:
assert false, "Failed to register: " & getCurrentExceptionMsg()
let rootUpdated = waitFor manager.updateRoots()
info "Updated root", rootUpdated
let proofRes = waitFor manager.fetchMerkleProofElements()
assert proofRes.isOk(), "failed to fetch merkle proof: " & proofRes.error
let goodCache = proofRes.get()
manager.merkleProofCache = goodCache
# Corrupt the cache to produce a proof with a bad Merkle root
manager.merkleProofCache = newSeq[byte](goodCache.len)
var restPort = Port(0)
let restAddress = parseIpAddress("0.0.0.0")
let restServer = WakuRestServerRef.init(restAddress, restPort).tryGet()
restPort = restServer.httpServer.address.port
let cache = MessageCache.init()
installRelayApiHandlers(restServer.router, node, cache)
restServer.start()
let client = newRestHttpClient(initTAddress(restAddress, restPort))
let simpleHandler = proc(
topic: PubsubTopic, msg: WakuMessage
): Future[void] {.async, gcsafe.} =
await sleepAsync(0.milliseconds)
node.subscribe((kind: ContentSub, topic: DefaultContentTopic), simpleHandler).isOkOr:
assert false, "Failed to subscribe to content topic"
let response = await client.relayPostAutoMessagesV1(
RelayWakuMessage(
payload: base64.encode("TEST-PAYLOAD"),
contentTopic: some(DefaultContentTopic),
timestamp: some(now()),
)
)
# The handler fails early with the retry signal; the refresh runs detached.
check:
response.status == 503
$response.contentType == $MIMETYPE_TEXT
response.data.contains(RlnProofRefreshScheduledMsg)
let inFlight = manager.proofPathRefreshInFlightFut
if not inFlight.isNil():
await inFlight.join()
check manager.merkleProofCache == goodCache
# A client retry now succeeds against the refreshed path.
let retryResponse = await client.relayPostAutoMessagesV1(
RelayWakuMessage(
payload: base64.encode("TEST-PAYLOAD"),
contentTopic: some(DefaultContentTopic),
timestamp: some(now()),
)
)
check:
retryResponse.status == 200
retryResponse.data == "OK"
await restServer.stop()
await restServer.closeWait()
await allFutures(node.stop(), meshNode.stop())