mirror of
https://github.com/logos-messaging/logos-delivery.git
synced 2026-08-03 18:33:15 +00:00
* feat: attach RLN proofs at the SendService transmission stage
The relay send path published without an RLN proof: proof generation
lived client-side in (legacy)lightpushPublish, so messages dispatched
through SendService -> RelaySendProcessor reached the network unproven
and would be rejected by an RLN-enforcing relay.
Adds Waku.attachRlnProof in the waku/api publish surface and calls it
from SendService immediately after admission, in both send() and the
retry loop. Placement is load-bearing:
- After admit(), so a message rejected by the rate limiter never draws
a nonce.
- At transmission rather than API entry, because a proof binds to the
epoch current when the message goes out, and a task can be retried
for up to MaxTimeInCache after send() returns.
attachRlnProof is a no-op without RLN mounted (message passes through
unproven, as today) and short-circuits on a message that already
carries a proof, so retrying a task neither redraws a nonce nor
changes the bytes. It uses generateRLNProofWithRootRefresh rather than
the plain generator: a task can wait in the task cache while the group
root moves on chain, so the proof is validated against the
acceptable-root window and regenerated once against a refetched merkle
path if it went stale.
Proof-generation failure parks the task as NextRoundRetry rather than
failing it, matching the admission path: the dominant failure is
NonceLimitReached (RLN's own per-epoch budget exhausted), which the
service loop resolves as the epoch rolls over.
Adds tests/messaging/test_rln_proof_attach.nim covering the unmounted
pass-through, attach when mounted, and the idempotency contract that
the retry loop depends on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
* test: cover currentRlnEpochQuota (mounted and unmounted)
currentRlnEpochQuota ships in the enforcement PR, but its mounted-RLN
assertion needs the anvil-backed group-manager scaffolding that lives in
this file, so the coverage rides along here: none when RLN is unmounted,
and the epoch index + userMessageLimit when it is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor: consolidate the RLN-rejection parking into parkForRlnProofRefresh
The relay and lightpush processors duplicated the RLN-rejection recovery
(schedule background refresh, clear the stale proof, reset admission,
park as NextRoundRetry); both now call parkForRlnProofRefresh in
send_processor, so the proof-clear the retry contract depends on cannot
drift between the two processors. Also resets firstAdmittedTime so the
regenerated proof's fresh nonce is re-admitted rather than sent uncharged.
No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: clarify that lightpush reuses an already-attached RLN proof
The old wording ("attaches an RLN proof per attempt") reads as if every
retry redraws a nonce. The flow proves a message only when it carries no
proof, so a task admitted once reuses its proof and nonce across retries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: retry once on scheduled RLN proof refresh in the lightpush REST handlers
The kernel lightpush publish paths no longer retry an RLN-invalid publish
inline. On a stale merkle root they schedule a background cache refresh and
return early, tagging the error with RlnProofRefreshScheduledMsg — retrying
is the caller's decision, so the send service recovers through its own loop
and the kernel exposes mechanism only.
The synchronous REST endpoints have no such loop: they call publish once and
map the result to an HTTP status. Left unchanged, a transient stale-root
rejection that the kernel previously absorbed via runRlnRefreshRetry would
now surface to the HTTP client as a 503. Restore the transparent retry where
it belongs under this layering — at the caller — instead of back in the
kernel where it would re-nest inside the send service's retry.
Both the legacy and v3 handlers now retry the publish exactly once when the
first result carries RlnProofRefreshScheduledMsg, under the same
FutTimeoutForPushRequestProcessing bound. The handler's message carries no
proof, so the retry regenerates against the refreshed merkle path. Any other
error, and any error on the retry itself, maps to its response as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: retry RLN proof attach on a charged-but-unproven task
admitAndProve set firstAdmittedTime before attaching the proof, then
guarded its whole body on firstAdmittedTime.isSome(). A transient proof
attach failure (e.g. NonceLimitReached) left the task charged but with an
empty proof, and the next round's early-return skipped the attach entirely
and shipped the message bare. The docstring's own invariant — "once
admitted, a task keeps its slot and its proof" — was violated: it kept the
slot but not the proof.
Guard only the rate-limit charge on firstAdmittedTime, not the attach.
attachRlnProof is already idempotent (short-circuits when RLN is unmounted
or a proof is present), so it is safe to call every round: a charged-but-
unproven task retries the attach until it sticks, then short-circuits. The
ordering invariant holds (charge strictly before attach, so an over-budget
message never draws a nonce), the charge stays once-per-task, and NO_PEERS
retries remain free.
This also removes a latent relay double-charge: previously a bare message
reached the relay, was rejected as RLN-invalid, and parkForRlnProofRefresh
reset firstAdmittedTime — re-charging a slot on the next round. The message
now never leaves unproven.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
347 lines
13 KiB
Nim
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),
|
|
)
|