stubbsta e9035c70e4
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>
2026-07-24 10:17:42 +02:00

347 lines
13 KiB
Nim

{.push raises: [].}
import
std/[hashes, strutils, tables, net],
chronos,
chronicles,
metrics,
results,
stew/byteutils,
eth/keys,
eth/p2p/discoveryv5/enr,
libp2p/crypto/crypto,
libp2p/protocols/ping,
libp2p/protocols/pubsub/gossipsub,
libp2p/protocols/pubsub/rpc/messages,
libp2p/builders,
libp2p/transports/tcptransport,
libp2p/transports/wstransport,
libp2p/utility,
libp2p_mix
import
../waku_node,
../../waku_core,
../../waku_core/topics/sharding,
../../waku_lightpush_legacy/client as legacy_lightpush_client,
../../waku_lightpush_legacy as legacy_lightpush_protocol,
../../waku_lightpush/client as lightpush_client,
../../waku_lightpush as lightpush_protocol,
../peer_manager,
../../common/rate_limit/setting,
../../rln
logScope:
topics = "waku node lightpush api"
const MountWithoutRelayError* = "cannot mount lightpush because relay is not mounted"
## Waku lightpush
proc mountLegacyLightPush*(
node: WakuNode, rateLimit: RateLimitSetting = DefaultGlobalNonRelayRateLimit
): Future[Result[void, string]] {.async.} =
info "mounting legacy light push"
if node.wakuRelay.isNil():
return err(MountWithoutRelayError)
info "mounting legacy lightpush with relay"
let pushHandler = legacy_lightpush_protocol.getRelayPushHandler(node.wakuRelay)
node.wakuLegacyLightPush = WakuLegacyLightPush.new(
node.peerManager, node.rng, pushHandler, Opt.some(rateLimit)
)
if node.started:
# Node has started already. Let's start lightpush too.
await node.wakuLegacyLightPush.start()
node.switch.mount(node.wakuLegacyLightPush, protocolMatcher(WakuLegacyLightPushCodec))
info "legacy lightpush mounted successfully"
return ok()
proc mountLegacyLightPushClient*(node: WakuNode) =
info "mounting legacy light push client"
if node.wakuLegacyLightpushClient.isNil():
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: Opt[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 legacyLightpushPublish*(
node: WakuNode,
pubsubTopic: Opt[PubsubTopic],
message: WakuMessage,
peer: RemotePeerInfo,
): Future[legacy_lightpush_protocol.WakuLightPushResult[string]] {.async, gcsafe.} =
## Pushes a `WakuMessage` to a node which relays it further on PubSub topic.
## Returns whether relaying was successful or not.
## `WakuMessage` should contain a `contentTopic` field for light node
## functionality.
if node.wakuLegacyLightpushClient.isNil() and node.wakuLegacyLightPush.isNil():
error "failed to publish message as legacy lightpush not available"
return err("Waku lightpush not available")
# 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 =
if node.rln.isNil():
Opt.none(Rln)
else:
Opt.some(node.rln)
let msgWithProof = (await checkAndGenerateRLNProof(rln, message)).valueOr:
return err("failed call checkAndGenerateRLNProof from lightpush: " & error)
try:
let pubsubForPublish = resolveLegacyPubsubTopic(
node, pubsubTopic, message.contentTopic
).valueOr:
return err(error)
let publishResult =
await internalLegacyLightpushPublish(node, pubsubForPublish, msgWithProof, peer)
# Legacy has no status codes, so string-match the RLN error to detect a
# stale merkle proof path. Schedule the refresh and hand the error back:
# retrying is the caller's decision, the same way the non-legacy path
# behaves. A retry regenerates the proof against the refreshed cache.
if publishResult.isOk() or rln.isNone() or
not publishResult.error.contains(RlnValidatorErrorMsg):
return publishResult
info "legacy lightpush send rejected as RLN-invalid; scheduling merkle proof refresh"
rln.get().groupManager.scheduleMerkleProofRefresh()
return err(RlnProofRefreshScheduledMsg & ": " & publishResult.error)
except CatchableError:
return err(getCurrentExceptionMsg())
# TODO: Move to application module (e.g., wakunode2.nim)
proc legacyLightpushPublish*(
node: WakuNode, pubsubTopic: Opt[PubsubTopic], message: WakuMessage
): Future[legacy_lightpush_protocol.WakuLightPushResult[string]] {.
async, gcsafe, deprecated: "Use 'node.legacyLightpushPublish()' instead"
.} =
if node.wakuLegacyLightpushClient.isNil() and node.wakuLegacyLightPush.isNil():
error "failed to publish message as legacy lightpush not available"
return err("waku legacy lightpush not available")
var peerOpt: Opt[RemotePeerInfo] = Opt.none(RemotePeerInfo)
if not node.wakuLegacyLightpushClient.isNil():
peerOpt = node.peerManager.selectPeer(WakuLegacyLightPushCodec)
if peerOpt.isNone():
let msg = "no suitable remote peers"
error "failed to publish message", err = msg
return err(msg)
elif not node.wakuLegacyLightPush.isNil():
peerOpt = Opt.some(RemotePeerInfo.init($node.switch.peerInfo.peerId))
return await node.legacyLightpushPublish(pubsubTopic, message, peer = peerOpt.get())
proc mountLightPush*(
node: WakuNode, rateLimit: RateLimitSetting = DefaultGlobalNonRelayRateLimit
): Future[Result[void, string]] {.async.} =
info "mounting light push"
if node.wakuRelay.isNil():
return err(MountWithoutRelayError)
info "mounting lightpush with relay"
let pushHandler = lightpush_protocol.getRelayPushHandler(node.wakuRelay)
node.wakuLightPush = WakuLightPush.new(
node.peerManager, node.rng, pushHandler, node.wakuAutoSharding, Opt.some(rateLimit)
)
if node.started:
# Node has started already. Let's start lightpush too.
await node.wakuLightPush.start()
node.switch.mount(node.wakuLightPush, protocolMatcher(WakuLightPushCodec))
info "lightpush mounted successfully"
return ok()
proc mountLightPushClient*(node: WakuNode) =
info "mounting light push client"
if node.wakuLightpushClient.isNil():
node.wakuLightpushClient = WakuLightPushClient.new(node.peerManager, node.rng)
proc lightpushPublishHandler(
node: WakuNode,
pubsubTopic: PubsubTopic,
message: WakuMessage,
peer: RemotePeerInfo | PeerInfo,
mixify: bool = false,
): Future[lightpush_protocol.WakuLightPushResult] {.async.} =
let msgHash = pubsubTopic.computeMessageHash(message).to0xHex()
if not node.wakuLightpushClient.isNil():
notice "publishing message with lightpush",
pubsubTopic = pubsubTopic,
contentTopic = message.contentTopic,
target_peer_id = peer.peerId,
msg_hash = msgHash,
mixify = mixify
if defined(libp2p_mix_experimental_exit_is_dest) and mixify:
#indicates we want to use mix to send the message
when defined(libp2p_mix_experimental_exit_is_dest):
#TODO: How to handle multiple addresses?
let conn = node.wakuMix.toConnection(
MixDestination.exitNode(peer.peerId),
WakuLightPushCodec,
MixParameters(expectReply: Opt.some(true), numSurbs: Opt.some(byte(1))),
# indicating we only want a single path to be used for reply hence numSurbs = 1
).valueOr:
error "could not create mix connection"
return lighpushErrorResult(
LightPushErrorCode.SERVICE_NOT_AVAILABLE,
"Waku lightpush with mix not available",
)
return
await node.wakuLightpushClient.publish(Opt.some(pubsubTopic), message, conn)
else:
return
await node.wakuLightpushClient.publish(Opt.some(pubsubTopic), message, peer)
if not node.wakuLightPush.isNil():
if mixify:
error "mixify is not supported with self hosted lightpush"
return lighpushErrorResult(
LightPushErrorCode.SERVICE_NOT_AVAILABLE,
"Waku lightpush with mix not available",
)
notice "publishing message with self hosted lightpush",
pubsubTopic = pubsubTopic,
contentTopic = message.contentTopic,
target_peer_id = peer.peerId,
msg_hash = msgHash
return await node.wakuLightPush.handleSelfLightPushRequest(
Opt.some(pubsubTopic), message
)
proc lightpushPublish*(
node: WakuNode,
pubsubTopic: Opt[PubsubTopic],
message: WakuMessage,
peerOpt: Opt[RemotePeerInfo] = Opt.none(RemotePeerInfo),
mixify: bool = false,
): Future[lightpush_protocol.WakuLightPushResult] {.async.} =
if node.wakuLightpushClient.isNil() and node.wakuLightPush.isNil():
error "failed to publish message as lightpush not available"
return lighpushErrorResult(
LightPushErrorCode.SERVICE_NOT_AVAILABLE, "Waku lightpush not available"
)
if mixify and node.wakuMix.isNil():
error "failed to publish message using mix as mix protocol is not mounted"
return lighpushErrorResult(
LightPushErrorCode.SERVICE_NOT_AVAILABLE, "Waku lightpush with mix not available"
)
let toPeer: RemotePeerInfo = peerOpt.valueOr:
if not node.wakuLightPush.isNil():
RemotePeerInfo.init(node.peerId())
elif not node.wakuLightpushClient.isNil():
node.peerManager.selectPeer(WakuLightPushCodec).valueOr:
let msg = "no suitable remote peers"
error "failed to publish message", msg = msg
return lighpushErrorResult(LightPushErrorCode.NO_PEERS_TO_RELAY, msg)
else:
return lighpushErrorResult(
LightPushErrorCode.NO_PEERS_TO_RELAY, "no suitable remote peers"
)
let pubsubForPublish = pubsubTopic.valueOr:
if node.wakuAutoSharding.isNone():
let msg = "Pubsub topic must be specified when static sharding is enabled"
error "lightpush publish error", error = msg
return lighpushErrorResult(LightPushErrorCode.INVALID_MESSAGE, msg)
let parsedTopic = NsContentTopic.parse(message.contentTopic).valueOr:
let msg = "Invalid content-topic:" & $error
error "lightpush request handling error", error = msg
return lighpushErrorResult(LightPushErrorCode.INVALID_MESSAGE, msg)
node.wakuAutoSharding.get().getShard(parsedTopic).valueOr:
let msg = "Autosharding error: " & error
error "lightpush publish error", error = msg
return lighpushErrorResult(LightPushErrorCode.INTERNAL_SERVER_ERROR, msg)
# 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 =
if node.rln.isNil():
Opt.none(Rln)
else:
Opt.some(node.rln)
let msgWithProof = (await checkAndGenerateRLNProof(rln, message)).valueOr:
return lighpushErrorResult(LightPushErrorCode.OUT_OF_RLN_PROOF, error)
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),
)