Skip to content

Commit f8ebf4d

Browse files
chaitanyapremclaude
andcommitted
refactor(mix): split DoS-protection self-registration into background retry
WakuMix.start() previously ran a single sequential pipeline that ended with mixRlnSpamProtection.registerSelf() — which publishes the new member's idCommitment to other mix nodes via relay. With master's flow (switch.start() then reconnectRelayPeers() then ...), running registerSelf inline would either: - block startup for ~62s when reconnectRelayPeers backs off, or - run before relay peers exist and silently no-op. Split the start() proc into: - method start*(WakuMix): local-only init (cred load, tree restore, plugin start). Safe to await before peers are connected. - proc registerDoSProtectionWithNetwork*(WakuMix): kick off a background task that retries registerSelf + saveTree indefinitely until the broadcast lands. Cancelled when WakuMix.stop() is invoked. waku_node.start() now wires these in order: switch.start() wakuMix.start() # local init, errors propagate reconnectRelayPeers() # may back off; no longer blocks mix wakuMix.registerDoSProtectionWithNetwork() # fire-and-forget retry node.started = true Re-call safety: registerDoSProtectionWithNetwork no-ops if a prior task is still running. Lifecycle: WakuMix.stop() cancelAndWait()s the task before calling plugin.stop(). For keystore-loaded nodes the first attempt early-returns successfully (membershipIndex already set); the retry path only exercises for fresh joins where the initial broadcast hits a not-yet-subscribed network. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a6dc13d commit f8ebf4d

2 files changed

Lines changed: 102 additions & 33 deletions

File tree

waku/node/waku_node.nim

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,12 @@ proc start*(node: WakuNode) {.async.} =
628628
# After switch.start, run custom Logos Delivery relay start logic
629629
await node.reconnectRelayPeers()
630630

631+
# Kick off the DoS-protection registration broadcast now that peers are
632+
# reconnected. Fire-and-forget: the proc returns immediately and an
633+
# internal background task retries until the broadcast lands.
634+
if not node.wakuMix.isNil():
635+
node.wakuMix.registerDoSProtectionWithNetwork()
636+
631637
node.started = true
632638

633639
if not node.wakuFilterClient.isNil():

waku/waku_mix/protocol.nim

Lines changed: 96 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ type
3939
pubKey*: Curve25519Key
4040
mixRlnSpamProtection*: MixRlnSpamProtection
4141
publishMessage*: PublishMessage
42+
dosRegistrationTask: Future[void]
43+
## Background task that retries DoS-protection self-registration until
44+
## it succeeds. nil until kicked off via registerDoSProtectionWithNetwork;
45+
## cancelled in stop().
4246

4347
WakuMixResult*[T] = Result[T, string]
4448

@@ -233,54 +237,113 @@ proc loadSpamProtectionTree*(mix: WakuMix): Result[void, string] =
233237
)
234238

235239
method start*(mix: WakuMix) {.async.} =
240+
## Local-only mix protocol initialization. Does NOT touch the network.
241+
## The network-dependent self-registration broadcast is handled separately
242+
## by registerDoSProtectionWithNetwork so that this proc can run before
243+
## peers are connected without blocking on relay startup.
236244
info "starting waku mix protocol"
237245

238-
# Set up spam protection callbacks and start
239246
if not mix.mixRlnSpamProtection.isNil():
240247
# Initialize spam protection (MixProtocol.init() does NOT call init() on the plugin)
241248
let initRes = await mix.mixRlnSpamProtection.init()
242249
if initRes.isErr:
243250
error "Failed to initialize spam protection", error = initRes.error
244-
else:
245-
# Load existing tree to sync with other members
246-
# This should be done after init() (which loads credentials)
247-
# but before registerSelf() (which adds us to the tree)
248-
let loadRes = mix.mixRlnSpamProtection.loadTree()
249-
if loadRes.isErr:
250-
debug "No existing tree found or failed to load, starting fresh",
251-
error = loadRes.error
252-
else:
253-
debug "Loaded existing spam protection membership tree from disk"
254-
255-
# Restore our credentials to the tree (after tree load, whether it succeeded or not)
256-
# This ensures our member is in the tree if we have an index from keystore
257-
let restoreRes = mix.mixRlnSpamProtection.restoreCredentialsToTree()
258-
if restoreRes.isErr:
259-
error "Failed to restore credentials to tree", error = restoreRes.error
260-
261-
# Set up publish callback (must be before start so registerSelf can use it)
262-
mix.setupSpamProtectionCallbacks()
263-
264-
let startRes = await mix.mixRlnSpamProtection.start()
265-
if startRes.isErr:
266-
error "Failed to start spam protection", error = startRes.error
267-
else:
268-
# Register self to broadcast membership to the network
269-
let registerRes = await mix.mixRlnSpamProtection.registerSelf()
270-
if registerRes.isErr:
271-
error "Failed to register spam protection credentials",
272-
error = registerRes.error
273-
else:
274-
debug "Registered spam protection credentials", index = registerRes.get()
251+
return
275252

276-
# Save tree to persist membership state
253+
# Load existing tree to sync with other members.
254+
# Should be done after init() (which loads credentials) but before
255+
# registerSelf() (which adds us to the tree).
256+
let loadRes = mix.mixRlnSpamProtection.loadTree()
257+
if loadRes.isErr:
258+
debug "No existing tree found or failed to load, starting fresh",
259+
error = loadRes.error
260+
else:
261+
debug "Loaded existing spam protection membership tree from disk"
262+
263+
# Restore our credentials to the tree (after tree load, whether it succeeded or not).
264+
# Ensures our member is in the tree if we have an index from keystore.
265+
let restoreRes = mix.mixRlnSpamProtection.restoreCredentialsToTree()
266+
if restoreRes.isErr:
267+
error "Failed to restore credentials to tree", error = restoreRes.error
268+
269+
# Set up publish callback. Must be before the network-side registration so
270+
# the plugin's groupManager.register can broadcast the membership update.
271+
mix.setupSpamProtectionCallbacks()
272+
273+
let startRes = await mix.mixRlnSpamProtection.start()
274+
if startRes.isErr:
275+
error "Failed to start spam protection", error = startRes.error
276+
277+
info "waku mix protocol started"
278+
279+
proc dosRegistrationRetryLoop(mix: WakuMix) {.async.} =
280+
## Indefinitely retry the DoS-protection self-registration broadcast until
281+
## it succeeds (or this task is cancelled by WakuMix.stop()). For nodes that
282+
## already have a membership index in their keystore, registerSelf early-
283+
## returns and the loop exits on the first attempt. For fresh nodes, the
284+
## broadcast needs at least one relay peer subscribed to the membership
285+
## topic to land — this loop survives transient "no peers yet" failures.
286+
##
287+
## TODO: Remove once RLN membership moves on-chain. With on-chain membership
288+
## peers discover each other via the contract / a watcher rather than via a
289+
## pubsub broadcast, so the retry loop (and the whole publishCallback path
290+
## from registerSelf) becomes unnecessary.
291+
##
292+
## Retry pacing uses exponential backoff (5s, 10s, 20s, ..., capped at 5min)
293+
## so persistent misconfiguration — e.g., relay never available — degrades
294+
## to one log line every 5 minutes after the initial ramp instead of every
295+
## 5 seconds forever.
296+
const InitialRetryDelay = chronos.seconds(5)
297+
const MaxRetryDelay = chronos.minutes(5)
298+
var delay = InitialRetryDelay
299+
while true:
300+
try:
301+
let registerRes = await mix.mixRlnSpamProtection.registerSelf()
302+
if registerRes.isOk():
303+
debug "DoS-protection self-registration succeeded",
304+
index = registerRes.get()
305+
# Persist tree only after a successful register — for fresh nodes this
306+
# captures the new index; for keystore nodes it's a harmless no-op.
277307
let saveRes = mix.mixRlnSpamProtection.saveTree()
278308
if saveRes.isErr:
279309
warn "Failed to save spam protection tree", error = saveRes.error
280310
else:
281311
trace "Saved spam protection tree to disk"
312+
return # success — exit the loop
313+
warn "DoS-protection self-registration failed, retrying",
314+
error = registerRes.error, nextDelay = delay
315+
except CancelledError as e:
316+
debug "DoS-protection registration loop cancelled"
317+
raise e
318+
except CatchableError as e:
319+
warn "DoS-protection registration raised, retrying",
320+
error = e.msg, nextDelay = delay
321+
await sleepAsync(delay)
322+
delay = min(delay * 2, MaxRetryDelay)
323+
324+
proc registerDoSProtectionWithNetwork*(mix: WakuMix) =
325+
## Kick off an indefinite background task that broadcasts this node's
326+
## DoS-protection (RLN) membership registration to other mix nodes via
327+
## relay. Returns immediately so callers don't block on a possibly-slow
328+
## broadcast. The task is cancelled when WakuMix.stop() is called.
329+
if mix.mixRlnSpamProtection.isNil():
330+
return
331+
# Guard against kicking off the retry loop when the plugin isn't actually
332+
# usable (e.g., mix.start()'s init/start steps failed). Without this check
333+
# the loop would spin forever logging "Plugin not initialized" warnings.
334+
if not mix.mixRlnSpamProtection.isReady():
335+
warn "Skipping DoS-protection registration: plugin not ready"
336+
return
337+
# Re-call safety: don't spawn a second loop if one is still in flight.
338+
if not mix.dosRegistrationTask.isNil and not mix.dosRegistrationTask.finished:
339+
debug "DoS-protection registration already in progress, skipping"
340+
return
341+
mix.dosRegistrationTask = mix.dosRegistrationRetryLoop()
282342

283343
method stop*(mix: WakuMix) {.async.} =
344+
# Cancel the in-flight DoS-protection registration retry loop, if any
345+
if not mix.dosRegistrationTask.isNil and not mix.dosRegistrationTask.finished:
346+
await mix.dosRegistrationTask.cancelAndWait()
284347
# Stop spam protection
285348
if not mix.mixRlnSpamProtection.isNil():
286349
await mix.mixRlnSpamProtection.stop()

0 commit comments

Comments
 (0)