Fix nonce rollback race: make rollbackNonce CAS-conditional

Two bugs in the previous blind-decrement rollbackNonce():

1. Concurrent draw: another proof generated between attempt 1 and the retry
   advances nextNonce, so decrementing steals a nonce already claimed by
   another live message — nullifier collision, on-chain slashable.

2. Pre-attached proof: if the incoming message already carries a proof,
   checkAndGenerateRLNProof short-circuits and draws no nonce, yet the retry
   still called rollback, stomping on whatever nonce the preceding message
   legitimately drew.

Fix: rollbackNonce now takes the specific nonce as a parameter and only
decrements when nextNonce == nonce + 1. checkAndGenerateRLNProof returns
(msg, messageId) so callers can thread the drawn id through to the retry
as reuseMessageId; generateRLNProof no longer performs an implicit rollback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
stubbsta 2026-07-06 15:01:16 +02:00
parent 6b75c6f921
commit e7afdce63e
No known key found for this signature in database
6 changed files with 84 additions and 60 deletions

View File

@ -92,7 +92,7 @@ proc legacyLightpushPublish*(
none(Rln)
else:
some(node.rln)
var msgWithProof = (await checkAndGenerateRLNProof(rln, message)).valueOr:
let (msgWithProof, drawnMessageId) = (await checkAndGenerateRLNProof(rln, message)).valueOr:
return err("failed call checkAndGenerateRLNProof from lightpush: " & error)
let internalPublish = proc(
@ -151,12 +151,17 @@ proc legacyLightpushPublish*(
info "legacy lightpush send rejected as RLN-invalid; " &
"refreshing merkle proof and retrying once"
msgWithProof = (
await checkAndGenerateRLNProof(rln, msgWithProof, forceMerkleProofRefresh = true)
let (retryMsg, _) = (
await checkAndGenerateRLNProof(
rln,
msgWithProof,
forceMerkleProofRefresh = true,
reuseMessageId = drawnMessageId,
)
).valueOr:
return err("failed call checkAndGenerateRLNProof from lightpush retry: " & error)
return await internalPublish(node, pubsubForPublish, msgWithProof, peer)
return await internalPublish(node, pubsubForPublish, retryMsg, peer)
except CatchableError:
return err(getCurrentExceptionMsg())
@ -318,7 +323,7 @@ proc lightpushPublish*(
none(Rln)
else:
some(node.rln)
var msgWithProof = (await checkAndGenerateRLNProof(rln, message)).valueOr:
let (msgWithProof, drawnMessageId) = (await checkAndGenerateRLNProof(rln, message)).valueOr:
return lighpushErrorResult(LightPushErrorCode.OUT_OF_RLN_PROOF, error)
let firstResult =
@ -334,10 +339,11 @@ proc lightpushPublish*(
info "lightpush send rejected; refreshing merkle proof and retrying once",
statusCode = $firstResult.error.code
msgWithProof = (
await checkAndGenerateRLNProof(rln, msgWithProof, forceMerkleProofRefresh = true)
let (retryMsg, _) = (
await checkAndGenerateRLNProof(
rln, msgWithProof, forceMerkleProofRefresh = true, reuseMessageId = drawnMessageId
)
).valueOr:
return lighpushErrorResult(LightPushErrorCode.OUT_OF_RLN_PROOF, error)
return
await lightpushPublishHandler(node, pubsubForPublish, msgWithProof, toPeer, mixify)
return await lightpushPublishHandler(node, pubsubForPublish, retryMsg, toPeer, mixify)

View File

@ -58,12 +58,12 @@ proc getNonce*(n: NonceManager): NonceManagerResult[Nonce] =
return ok(retNonce)
proc rollbackNonce*(n: NonceManager) =
# Undo the last `getNonce` so the message id can be reissued. Used when a
# proof was generated but never left the node (e.g. a lightpush retry
# after a 420/504 rejection): the rejected attempt never reached the
# network, so letting the retry reuse the same id keeps one delivered
# message consuming one id out of the per-epoch userMessageLimit instead
# of two.
if n.nextNonce > 0:
n.nextNonce = n.nextNonce - 1
proc rollbackNonce*(n: NonceManager, nonce: Nonce): bool =
# CAS: only give back `nonce` when it is still the last id issued.
# If another draw advanced the counter between this attempt and the retry,
# leave the counter alone — the retry will draw a fresh id rather than
# risk a nullifier collision with the intervening message.
if n.nextNonce == nonce + 1:
n.nextNonce = nonce
return true
false

View File

@ -61,13 +61,6 @@ proc generateRLNProof*(
forceMerkleProofRefresh: bool = false,
): Future[RlnResult[seq[byte]]] {.async.} =
let epoch = rln.calcEpoch(senderEpochTime)
# A forced refresh is a retry after the prior attempt was rejected before
# it reached the network. That attempt already consumed a message id, so
# give it back before drawing a new one — otherwise one delivered message
# eats two ids of the per-epoch userMessageLimit, moving the sender
# closer to the on-chain slashing threshold for no reason.
if forceMerkleProofRefresh:
rln.nonceManager.rollbackNonce()
let nonce = rln.nonceManager.getNonce().valueOr:
return err("could not get new message id to generate an rln proof: " & $error)
let proof = (
@ -79,28 +72,46 @@ proc generateRLNProof*(
return ok(proof.encode().buffer)
proc checkAndGenerateRLNProof*(
rln: Option[Rln], message: WakuMessage, forceMerkleProofRefresh: bool = false
): Future[Result[WakuMessage, string]] {.async.} =
# When forcing a refresh (e.g. retry after a lightpush 420/504 rejection) we
# deliberately regenerate the proof even if one is already attached, since
# the existing proof is presumed to have been rejected.
rln: Option[Rln],
message: WakuMessage,
forceMerkleProofRefresh: bool = false,
reuseMessageId: Option[Nonce] = none(Nonce),
): Future[Result[tuple[msg: WakuMessage, messageId: Option[Nonce]], string]] {.async.} =
## Returns the message with an attached RLN proof and the message id drawn
## from the nonce manager (`messageId = none` when no id was consumed —
## message already had a valid proof, or RLN is not configured).
## Pass `messageId` back as `reuseMessageId` on a retry so the CAS rollback
## can reclaim it when no concurrent draw has advanced the counter.
if message.proof.len > 0 and not forceMerkleProofRefresh:
return ok(message)
return ok((msg: message, messageId: none(Nonce)))
if rln.isNone():
notice "Publishing message without RLN proof"
return ok(message)
return ok((msg: message, messageId: none(Nonce)))
let r = rln.get()
if reuseMessageId.isSome():
if not r.nonceManager.rollbackNonce(reuseMessageId.get()):
# A concurrent draw advanced the counter past the rejected attempt's id;
# the retry will consume a fresh message id instead of reusing the old one.
debug "rln retry: concurrent draw prevented nonce reuse; consuming a fresh message id",
attempted = reuseMessageId.get(), nextNonce = r.nonceManager.nextNonce
let
time = getTime().toUnix()
senderEpochTime = float64(time)
epoch = r.calcEpoch(senderEpochTime)
let nonce = r.nonceManager.getNonce().valueOr:
return err("could not get new message id to generate an rln proof: " & $error)
var msgWithProof = message
msgWithProof.proof = (
await rln.get().generateRLNProof(
let proof = (
await r.groupManager.generateProof(
msgWithProof.toRLNSignal(),
senderEpochTime,
epoch,
nonce,
forceMerkleProofRefresh = forceMerkleProofRefresh,
)
).valueOr:
return err("error in checkAndGenerateRLNProof: " & $error)
return ok(msgWithProof)
msgWithProof.proof = proof.encode().buffer
return ok((msg: msgWithProof, messageId: some(nonce)))

View File

@ -166,7 +166,7 @@ suite "RLN Proofs as a Lightpush Service":
# Attach the RLN proof. In production the client mounts RLN and generates the
# proof in legacyLightpushPublish; here we generate it using the server's RLN
# instance since both ends share group state via the in-memory manager.
let msgWithProof =
let (msgWithProof, _) =
(await checkAndGenerateRLNProof(some(server.rln), message)).get()
# When the client publishes a message
@ -186,7 +186,8 @@ suite "RLN Proofs as a Lightpush Service":
# "RLN validation failed" rejection: an already attached proof must NOT
# short-circuit checkAndGenerateRLNProof when forceMerkleProofRefresh=true,
# and the cache must be refetched from chain instead of trusted.
let firstMsg = (await checkAndGenerateRLNProof(some(server.rln), message)).get()
let (firstMsg, drawnNonce) =
(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
@ -197,10 +198,14 @@ suite "RLN Proofs as a Lightpush Service":
check manager.merkleProofCache != goodCache
# Force-regenerate. The existing proof must be discarded, the cache
# refetched from chain, and a fresh proof produced.
let secondMsg = (
# refetched from chain, and a fresh proof produced. Pass drawnNonce so
# the CAS rollback reclaims it — mirroring the production retry path.
let (secondMsg, _) = (
await checkAndGenerateRLNProof(
some(server.rln), firstMsg, forceMerkleProofRefresh = true
some(server.rln),
firstMsg,
forceMerkleProofRefresh = true,
reuseMessageId = drawnNonce,
)
).get()

View File

@ -162,7 +162,7 @@ suite "RLN Proofs as a Lightpush Service":
# Attach the RLN proof. In production the client mounts RLN and generates the
# proof in lightpushPublish; here we generate it using the server's RLN instance
# since both ends share group state via the in-memory manager.
let msgWithProof =
let (msgWithProof, _) =
(await checkAndGenerateRLNProof(some(server.rln), message)).get()
# When the client publishes a message
@ -183,7 +183,8 @@ suite "RLN Proofs as a Lightpush Service":
# attached proof must NOT short-circuit checkAndGenerateRLNProof when
# forceMerkleProofRefresh=true, and the cache must be refetched from
# chain instead of trusted.
let firstMsg = (await checkAndGenerateRLNProof(some(server.rln), message)).get()
let (firstMsg, drawnNonce) =
(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
@ -194,10 +195,14 @@ suite "RLN Proofs as a Lightpush Service":
check manager.merkleProofCache != goodCache
# Force-regenerate. The existing proof must be discarded, the cache
# refetched from chain, and a fresh proof produced.
let secondMsg = (
# refetched from chain, and a fresh proof produced. Pass drawnNonce so
# the CAS rollback reclaims it — mirroring the production retry path.
let (secondMsg, _) = (
await checkAndGenerateRLNProof(
some(server.rln), firstMsg, forceMerkleProofRefresh = true
some(server.rln),
firstMsg,
forceMerkleProofRefresh = true,
reuseMessageId = drawnNonce,
)
).get()

View File

@ -42,11 +42,11 @@ suite "Nonce manager":
nonce == 0.uint
nonce2 == 0.uint
test "rollbackNonce lets the next getNonce reissue the same id":
test "rollbackNonce reclaims the last-drawn id when counter has not advanced":
let nm = NonceManager.init(nonceLimit = 100.uint)
let first = nm.getNonce().valueOr:
raiseAssert $error
nm.rollbackNonce()
check nm.rollbackNonce(first)
let reissued = nm.getNonce().valueOr:
raiseAssert $error
@ -55,23 +55,20 @@ suite "Nonce manager":
reissued == first
nm.nextNonce == 1.uint
test "rollbackNonce frees the message id so nonceLimit is not exceeded":
let nm = NonceManager.init(nonceLimit = 1.uint)
test "rollbackNonce is a no-op when another draw has advanced the counter":
let nm = NonceManager.init(nonceLimit = 100.uint)
let first = nm.getNonce().valueOr:
raiseAssert $error
# A second draw slips in — simulates concurrent publish between attempt 1
# and the retry. The counter now points past first+1.
discard nm.getNonce().valueOr:
raiseAssert $error
# Without rollback a second call would fail (nonceLimit = 1, i.e. one
# message id per epoch). Rollback returns the id and the retry succeeds
# using the same id — the on-chain slashing threshold is not crossed.
nm.rollbackNonce()
let retry = nm.getNonce()
check:
retry.isOk()
retry.get() == 0.uint
check not nm.rollbackNonce(first)
check nm.nextNonce == 2.uint
test "rollbackNonce is a no-op when no nonce has been issued":
let nm = NonceManager.init(nonceLimit = 100.uint)
nm.rollbackNonce()
check not nm.rollbackNonce(Nonce(0))
check:
nm.nextNonce == 0.uint