Skip to content

Commit f4f21ba

Browse files
stubbstaclaude
andcommitted
feat: handle RLN publish rejections in the send service retry loop
An RLN-invalid publish rejection now recovers through the send service's existing retry loop instead of an inline retry in the kernel. When a relay or lightpush publish is rejected as RLN-invalid, the processor clears the message's stale proof, schedules a background merkle-proof refresh, and parks the task as NextRoundRetry. The next loop round re-admits the task and regenerates the proof against the refreshed path. Clearing the proof is required: attachRlnProof short-circuits on a message that already carries one, so without the clear the task would resend the rejected proof until it ages out. The relay processor previously failed such tasks outright, with no recovery. Kernel changes supporting this: - Remove runRlnRefreshRetry from legacyLightpushPublish. The legacy path now schedules the refresh and returns the error tagged with RlnProofRefreshScheduledMsg, matching the non-legacy path; retrying is the caller's decision. Drops the now-unused RlnMerkleProofRefreshTimeout. - generateRLNProofWithRootRefresh reuses the nonce drawn for the first attempt when it regenerates after a stale root, rather than drawing a second. Only the merkle path differs between the two attempts, so a redraw would spend two message ids from the epoch budget on a single message and drift the rate limit manager's accounting away from the nonce manager's. Adds Waku.isRlnRejection / Waku.onRlnProofRejected as the messaging layer's handle on the kernel's RLN rejection detection and background refresh. Updates the legacy lightpush tests to the schedule-refresh contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1425088 commit f4f21ba

8 files changed

Lines changed: 109 additions & 78 deletions

File tree

logos_delivery/messaging/delivery_service/send_service/lightpush_processor.nim

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ method sendImpl*(
3636
await self.waku.lightpushPublishToAny(task.pubsubTopic, task.msg)
3737
).valueOr:
3838
error "LightpushSendProcessor.sendImpl failed", error = error.desc.get($error.code)
39+
40+
if error.isRlnRejection():
41+
## The proof was refused, so it must not be sent again: drop it and let
42+
## the refreshed merkle path produce a new one on the next round.
43+
## Re-admission gates the regeneration, so a task cannot spin through the
44+
## epoch budget by retrying.
45+
self.waku.onRlnProofRejected()
46+
task.msg.proof = @[]
47+
task.state = DeliveryState.NextRoundRetry
48+
return
49+
3950
case error.code
4051
of LightPushErrorCode.NO_PEERS_TO_RELAY, LightPushErrorCode.TOO_MANY_REQUESTS,
4152
LightPushErrorCode.OUT_OF_RLN_PROOF, LightPushErrorCode.SERVICE_NOT_AVAILABLE,

logos_delivery/messaging/delivery_service/send_service/relay_processor.nim

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import std/options
33
import chronos, chronicles
44
import brokers/broker_context
55
import logos_delivery/waku/[waku_core], logos_delivery/waku/waku_lightpush/[common, rpc]
6+
import logos_delivery/waku/waku, logos_delivery/waku/api/publish
67
import logos_delivery/waku/requests/health_requests
78
import logos_delivery/api/types
89
import ./[delivery_task, send_processor]
@@ -11,13 +12,15 @@ logScope:
1112
topics = "send service relay processor"
1213

1314
type RelaySendProcessor* = ref object of BaseSendProcessor
15+
waku: Waku
1416
publishProc: PushMessageHandler
1517
fallbackStateToSet: DeliveryState
1618

1719
proc new*(
1820
T: typedesc[RelaySendProcessor],
1921
lightpushAvailable: bool,
2022
publishProc: PushMessageHandler,
23+
waku: Waku,
2124
brokerCtx: BrokerContext,
2225
): RelaySendProcessor =
2326
let fallbackStateToSet =
@@ -27,6 +30,7 @@ proc new*(
2730
DeliveryState.FailedToDeliver
2831

2932
return RelaySendProcessor(
33+
waku: waku,
3034
publishProc: publishProc,
3135
fallbackStateToSet: fallbackStateToSet,
3236
brokerCtx: brokerCtx,
@@ -62,6 +66,17 @@ method sendImpl*(self: RelaySendProcessor, task: DeliveryTask) {.async.} =
6266
let errorMessage = error.desc.get($error.code)
6367
error "Failed to publish message with relay",
6468
request = task.requestId, msgHash = task.msgHash.to0xHex(), error = errorMessage
69+
70+
if error.isRlnRejection():
71+
## The relay validator refused the proof. Dropping it and retrying is not
72+
## the same as failing: the message is valid, its proof went stale against
73+
## a moved merkle root. Clearing it makes the next round regenerate one
74+
## against the refreshed path.
75+
self.waku.onRlnProofRejected()
76+
task.msg.proof = @[]
77+
task.state = DeliveryState.NextRoundRetry
78+
return
79+
6580
if error.code != LightPushErrorCode.NO_PEERS_TO_RELAY:
6681
task.state = DeliveryState.FailedToDeliver
6782
task.errorDesc = errorMessage

logos_delivery/messaging/delivery_service/send_service/send_service.nim

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ proc setupSendProcessorChain(
6969

7070
if isRelayAvail:
7171
let publishProc = waku.relayPushHandler()
72-
processors.add(RelaySendProcessor.new(isLightPushAvail, publishProc, brokerCtx))
72+
processors.add(
73+
RelaySendProcessor.new(isLightPushAvail, publishProc, waku, brokerCtx)
74+
)
7375
if isLightPushAvail:
7476
processors.add(LightpushSendProcessor.new(waku, brokerCtx))
7577

logos_delivery/waku/api/publish.nim

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import logos_delivery/waku/compat/option_valueor
99
{.push raises: [].}
1010

11-
import std/[options, times]
11+
import std/[options, times, strutils]
1212
import results, chronos
1313

1414
import logos_delivery/waku/waku
@@ -68,6 +68,31 @@ proc attachRlnProof*(
6868

6969
return ok(msgWithProof)
7070

71+
func isRlnRejection*(error: ErrorStatus): bool =
72+
## True when a publish failure means "the RLN proof was not accepted", so the
73+
## message is worth retrying with a freshly generated proof rather than being
74+
## failed outright.
75+
##
76+
## OUT_OF_RLN_PROOF is always RLN. INVALID_MESSAGE also covers non-RLN
77+
## rejections (an oversized message, say), so it additionally has to carry the
78+
## validator's error marker — this is the same gate the kernel lightpush path
79+
## applies before scheduling a refresh.
80+
return
81+
error.code == LightPushErrorCode.OUT_OF_RLN_PROOF or (
82+
error.code == LightPushErrorCode.INVALID_MESSAGE and
83+
error.desc.get("").contains(RlnValidatorErrorMsg)
84+
)
85+
86+
proc onRlnProofRejected*(self: Waku) =
87+
## Called when a publish was rejected as RLN-invalid. Starts refetching the
88+
## merkle path in the background, so the next proof generated for the message
89+
## is built against a fresh one. Non-blocking: the send service's own loop is
90+
## what retries, and it must not stall waiting on an RPC round trip.
91+
if self.node.rln.isNil():
92+
return
93+
94+
self.node.rln.groupManager.scheduleMerkleProofRefresh()
95+
7196
proc lightpushPeerAvailable*(self: Waku, shard: PubsubTopic): bool =
7297
## True if a lightpush service peer is available for `shard`.
7398
return self.node.peerManager.selectPeer(WakuLightPushCodec, some(shard)).isSome()

logos_delivery/waku/node/waku_node/lightpush.nim

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -105,30 +105,6 @@ proc resolveLegacyPubsubTopic(
105105
return err("Autosharding error: " & error)
106106
return ok($shard)
107107

108-
proc runRlnRefreshRetry(
109-
node: WakuNode,
110-
rln: Option[Rln],
111-
msgWithProof: WakuMessage,
112-
pubsubForPublish: PubsubTopic,
113-
peer: RemotePeerInfo,
114-
fallback: legacy_lightpush_protocol.WakuLightPushResult[string],
115-
): Future[legacy_lightpush_protocol.WakuLightPushResult[string]] {.async, gcsafe.} =
116-
## Refreshes the RLN merkle proof path and retries the publish once. Only the
117-
## refresh is bounded by RlnMerkleProofRefreshTimeout (returning `fallback` on
118-
## timeout); the retried publish runs unbounded, matching the first attempt.
119-
info "legacy lightpush send rejected as RLN-invalid; " &
120-
"refreshing merkle proof and retrying once"
121-
rln.get().groupManager.invalidateMerkleProofCache()
122-
123-
let refreshFut = attachRLNProof(rln.get(), msgWithProof)
124-
if not (await refreshFut.withTimeout(RlnMerkleProofRefreshTimeout)):
125-
warn "legacy lightpush RLN proof refresh timed out; returning original error"
126-
return fallback
127-
let retryMsg = refreshFut.read().valueOr:
128-
return err("failed call attachRLNProof from lightpush retry: " & error)
129-
130-
return await internalLegacyLightpushPublish(node, pubsubForPublish, retryMsg, peer)
131-
132108
proc legacyLightpushPublish*(
133109
node: WakuNode,
134110
pubsubTopic: Option[PubsubTopic],
@@ -161,18 +137,20 @@ proc legacyLightpushPublish*(
161137
).valueOr:
162138
return err(error)
163139

164-
let firstResult =
140+
let publishResult =
165141
await internalLegacyLightpushPublish(node, pubsubForPublish, msgWithProof, peer)
166142

167143
# Legacy has no status codes, so string-match the RLN error to detect a
168-
# stale merkle proof path, then refresh and retry once.
169-
if firstResult.isOk() or rln.isNone() or
170-
not firstResult.error.contains(RlnValidatorErrorMsg):
171-
return firstResult
172-
173-
return await runRlnRefreshRetry(
174-
node, rln, msgWithProof, pubsubForPublish, peer, firstResult
175-
)
144+
# stale merkle proof path. Schedule the refresh and hand the error back:
145+
# retrying is the caller's decision, the same way the non-legacy path
146+
# behaves. A retry regenerates the proof against the refreshed cache.
147+
if publishResult.isOk() or rln.isNone() or
148+
not publishResult.error.contains(RlnValidatorErrorMsg):
149+
return publishResult
150+
151+
info "legacy lightpush send rejected as RLN-invalid; scheduling merkle proof refresh"
152+
rln.get().groupManager.scheduleMerkleProofRefresh()
153+
return err(RlnProofRefreshScheduledMsg & ": " & publishResult.error)
176154
except CatchableError:
177155
return err(getCurrentExceptionMsg())
178156

logos_delivery/waku/rln/constants.nim

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,6 @@ const RlnValidatorErrorMsg* = "RLN validation failed"
2525
const RlnProofRefreshScheduledMsg* =
2626
"stale RLN proof suspected; refresh scheduled, retry the publish"
2727

28-
# Bounds the legacy lightpush merkle proof refresh (eth_call refetch + proof
29-
# regen) so a hanging RPC cannot stall the caller. The retried publish is not
30-
# bounded.
31-
const RlnMerkleProofRefreshTimeout* = 5.seconds
32-
3328
# inputs of the membership contract constructor
3429
# TODO may be able to make these constants private and put them inside the waku_rln_utils
3530
const

logos_delivery/waku/rln/proof.nim

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,41 @@ proc toRLNSignal*(wakumessage: WakuMessage): seq[byte] =
5454
output = concat(wakumessage.payload, contentTopicBytes, @(timestampBytes))
5555
return output
5656

57-
proc generateRLNProof*(
58-
rln: Rln, input: seq[byte], senderEpochTime: float64
57+
proc generateRLNProofWithNonce(
58+
rln: Rln, input: seq[byte], senderEpochTime: float64, nonce: Nonce
5959
): Future[Result[seq[byte], string]] {.async.} =
60+
## Generates a proof against an already drawn `nonce`. Regenerating for an
61+
## unchanged (input, epoch, nonce) is safe: the revealed share is a function
62+
## of those three, so a regenerated proof reveals the same share and cannot
63+
## read as double-signalling.
6064
let epoch = rln.calcEpoch(senderEpochTime)
61-
let nonce = rln.nonceManager.getNonce().valueOr:
62-
return err("could not get new message id to generate an rln proof: " & $error)
6365
let proof = (await rln.groupManager.generateProof(input, epoch, nonce)).valueOr:
6466
return err("could not generate rln-v2 proof: " & $error)
6567
return ok(proof.encode().buffer)
6668

69+
proc generateRLNProof*(
70+
rln: Rln, input: seq[byte], senderEpochTime: float64
71+
): Future[Result[seq[byte], string]] {.async.} =
72+
let nonce = rln.nonceManager.getNonce().valueOr:
73+
return err("could not get new message id to generate an rln proof: " & $error)
74+
return await rln.generateRLNProofWithNonce(input, senderEpochTime, nonce)
75+
6776
proc generateRLNProofWithRootRefresh*(
6877
rln: Rln, input: seq[byte], senderEpochTime: float64
6978
): Future[Result[seq[byte], string]] {.async.} =
7079
## Generates an RLN proof and checks its merkle root against the
7180
## acceptable-root window. If the root is stale, invalidates the cache and
7281
## regenerates once against a refetched path. Returns the proof bytes.
73-
let proofBytes = (await rln.generateRLNProof(input, senderEpochTime)).valueOr:
82+
##
83+
## The regeneration reuses the nonce drawn for the first attempt: only the
84+
## merkle path differs between the two, so drawing again would spend two
85+
## message ids from the epoch budget on a message that is sent once. That
86+
## would drift the budget the rate limit manager accounts for away from the
87+
## one the nonce manager enforces.
88+
let nonce = rln.nonceManager.getNonce().valueOr:
89+
return err("could not get new message id to generate an rln proof: " & $error)
90+
91+
let proofBytes = (await rln.generateRLNProofWithNonce(input, senderEpochTime, nonce)).valueOr:
7492
return err("failed to generate RLN proof: " & $error)
7593

7694
let rlnProof = RateLimitProof.init(proofBytes).valueOr:
@@ -81,7 +99,7 @@ proc generateRLNProofWithRootRefresh*(
8199

82100
info "RLN: stale merkle root detected; refreshing merkle path and regenerating proof"
83101
rln.groupManager.invalidateMerkleProofCache()
84-
return await rln.generateRLNProof(input, senderEpochTime)
102+
return await rln.generateRLNProofWithNonce(input, senderEpochTime, nonce)
85103

86104
proc attachRLNProof*(
87105
r: Rln, message: WakuMessage

tests/node/test_wakunode_legacy_lightpush.nim

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -186,30 +186,35 @@ suite "RLN Proofs as a Lightpush Service":
186186

187187
# The tests below drive `server.legacyLightpushPublish(...)` against the
188188
# server node. Because `server.wakuLegacyLightPush` is mounted (and no
189-
# legacy client is), the call takes the self-request path — it still runs
190-
# the full client-side flow (proof gen, retry on RlnValidatorErrorMsg
191-
# substring, one-retry cap), but the request lands in the local
189+
# legacy client is), the call takes the self-request path — it runs the
190+
# full client-side flow (proof gen, RLN-rejection detection via the
191+
# RlnValidatorErrorMsg substring), but the request lands in the local
192192
# pushHandler. Swapping in a stub pushHandler lets each test control what
193-
# attempt N sees.
194-
195-
asyncTest "retry fires on RlnValidatorErrorMsg substring and second attempt succeeds":
193+
# the publish attempt sees.
194+
#
195+
# On an RLN rejection the publish schedules a background merkle-proof
196+
# refresh and returns the error tagged with RlnProofRefreshScheduledMsg;
197+
# the caller (the send service loop) regenerates the proof and republishes
198+
# on its next round.
199+
200+
asyncTest "RLN rejection schedules a refresh and surfaces the tagged error":
196201
var callCount = 0
197202
let stub: PushMessageHandler = proc(
198203
pubsubTopic: PubsubTopic, message: WakuMessage
199204
): Future[WakuLightPushResult[void]] {.async.} =
200205
inc callCount
201-
if callCount == 1:
202-
return err(RlnValidatorErrorMsg & ": simulated stale merkle path")
203-
return ok()
206+
return err(RlnValidatorErrorMsg & ": simulated stale merkle path")
204207
server.wakuLegacyLightPush.pushHandler = stub
205208

206209
let response = await server.legacyLightpushPublish(
207210
some(pubsubTopic), message, server.peerInfo.toRemotePeerInfo()
208211
)
209212

210213
check:
211-
callCount == 2
212-
response.isOk()
214+
callCount == 1
215+
response.isErr()
216+
response.error.contains(RlnProofRefreshScheduledMsg)
217+
response.error.contains(RlnValidatorErrorMsg)
213218

214219
asyncTest "no retry when error does not contain RlnValidatorErrorMsg":
215220
var callCount = 0
@@ -229,27 +234,9 @@ suite "RLN Proofs as a Lightpush Service":
229234
response.isErr()
230235
response.error == "unrelated failure"
231236

232-
asyncTest "retry cap: two consecutive RLN errors surface the second":
233-
var callCount = 0
234-
let stub: PushMessageHandler = proc(
235-
pubsubTopic: PubsubTopic, message: WakuMessage
236-
): Future[WakuLightPushResult[void]] {.async.} =
237-
inc callCount
238-
return err(RlnValidatorErrorMsg & ": still stale")
239-
server.wakuLegacyLightPush.pushHandler = stub
240-
241-
let response = await server.legacyLightpushPublish(
242-
some(pubsubTopic), message, server.peerInfo.toRemotePeerInfo()
243-
)
244-
245-
check:
246-
callCount == 2
247-
response.isErr()
248-
response.error.contains(RlnValidatorErrorMsg)
249-
250-
asyncTest "no retry when node.rln is nil":
251-
# Detach RLN so the retry branch short-circuits on rln.isNone() even
252-
# when the error string carries RlnValidatorErrorMsg. Restore before
237+
asyncTest "no refresh scheduled when node.rln is nil":
238+
# Detach RLN so the RLN-rejection branch short-circuits on rln.isNone()
239+
# even when the error string carries RlnValidatorErrorMsg. Restore before
253240
# teardown so server.stop() sees the same object graph it was
254241
# constructed with.
255242
let savedRln = server.rln

0 commit comments

Comments
 (0)