Skip to content

Commit 51d875d

Browse files
feat: make PublishCallback return Result and track broadcast futures (#11)
Previously PublishCallback returned Future[void], so publish errors were silently dropped, and the proof-metadata broadcast in the synchronous verifyProof method was fired via an untracked asyncSpawn. - PublishCallback now returns Future[Result[void, string]] so callers can handle publish failures. - group_manager membership broadcasts handle the returned error (warn). - verifyProof is a sync method (overrides a sync base), so it cannot await; its proof-metadata broadcast now runs as a tracked background future (pruned when finished, cancelled in stop()) instead of asyncSpawn. - update createLoggingPublishCallback and doc examples accordingly. Addresses review feedback on logos-delivery#3931.
1 parent 61ee3e5 commit 51d875d

5 files changed

Lines changed: 105 additions & 89 deletions

File tree

src/mix_rln_spam_protection.nim

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@
2626
## await plugin.init()
2727
##
2828
## # Set up logos-messaging integration
29-
## plugin.setPublishCallback(proc(topic: string, data: seq[byte]) {.async.} =
29+
## plugin.setPublishCallback(proc(
30+
## topic: string, data: seq[byte]
31+
## ): Future[Result[void, string]] {.async.} =
3032
## await logosMessaging.publish(topic, data)
33+
## ok()
3134
## )
3235
##
3336
## # Start the plugin
@@ -71,26 +74,10 @@
7174

7275
import
7376
./mix_rln_spam_protection/[
74-
types,
75-
constants,
76-
protobuf,
77-
codec,
78-
rln_interface,
79-
group_manager,
80-
nullifier_log,
81-
spam_protection,
82-
coordination,
83-
credentials
77+
types, constants, protobuf, codec, rln_interface, group_manager, nullifier_log,
78+
spam_protection, coordination, credentials,
8479
]
8580

8681
export
87-
types,
88-
constants,
89-
protobuf,
90-
codec,
91-
spam_protection,
92-
coordination,
93-
credentials,
94-
group_manager,
95-
nullifier_log,
96-
rln_interface
82+
types, constants, protobuf, codec, spam_protection, coordination, credentials,
83+
group_manager, nullifier_log, rln_interface

src/mix_rln_spam_protection/coordination.nim

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ logScope:
2525
type
2626
# Subscription handler types
2727
MembershipSubscriptionHandler* = proc(update: MembershipUpdate) {.gcsafe, raises: [].}
28-
MetadataSubscriptionHandler* = proc(metadata: ProofMetadataBroadcast) {.gcsafe, raises: [].}
28+
MetadataSubscriptionHandler* =
29+
proc(metadata: ProofMetadataBroadcast) {.gcsafe, raises: [].}
2930

3031
# Coordination layer wrapper
3132
CoordinationLayer* = ref object
@@ -45,7 +46,7 @@ proc newCoordinationLayer*(spamProtection: MixRlnSpamProtection): CoordinationLa
4546
spamProtection: spamProtection,
4647
publishCallback: none(PublishCallback),
4748
onMembershipUpdate: none(MembershipSubscriptionHandler),
48-
onProofMetadata: none(MetadataSubscriptionHandler)
49+
onProofMetadata: none(MetadataSubscriptionHandler),
4950
)
5051

5152
proc setPublishCallback*(cl: CoordinationLayer, callback: PublishCallback) =
@@ -54,18 +55,20 @@ proc setPublishCallback*(cl: CoordinationLayer, callback: PublishCallback) =
5455
cl.publishCallback = some(callback)
5556
cl.spamProtection.setPublishCallback(callback)
5657

57-
proc setMembershipUpdateHandler*(cl: CoordinationLayer, handler: MembershipSubscriptionHandler) =
58+
proc setMembershipUpdateHandler*(
59+
cl: CoordinationLayer, handler: MembershipSubscriptionHandler
60+
) =
5861
## Set additional handler for membership updates (for custom processing).
5962
cl.onMembershipUpdate = some(handler)
6063

61-
proc setProofMetadataHandler*(cl: CoordinationLayer, handler: MetadataSubscriptionHandler) =
64+
proc setProofMetadataHandler*(
65+
cl: CoordinationLayer, handler: MetadataSubscriptionHandler
66+
) =
6267
## Set additional handler for proof metadata (for custom processing).
6368
cl.onProofMetadata = some(handler)
6469

6570
proc handleIncomingMessage*(
66-
cl: CoordinationLayer,
67-
contentTopic: string,
68-
data: seq[byte]
71+
cl: CoordinationLayer, contentTopic: string, data: seq[byte]
6972
): Future[RlnResult[void]] {.async.} =
7073
## Route incoming messages from logos-messaging to appropriate handlers.
7174
##
@@ -85,7 +88,6 @@ proc handleIncomingMessage*(
8588
let update = MembershipUpdate.decode(data).valueOr:
8689
return err("Failed to decode update for handler: " & $error)
8790
cl.onMembershipUpdate.get()(update)
88-
8991
elif contentTopic == metadataTopic:
9092
# Handle proof metadata
9193
let metadataResult = cl.spamProtection.handleProofMetadata(data)
@@ -97,7 +99,6 @@ proc handleIncomingMessage*(
9799
let metadata = ProofMetadataBroadcast.decode(data).valueOr:
98100
return err("Failed to decode metadata for handler: " & $error)
99101
cl.onProofMetadata.get()(metadata)
100-
101102
else:
102103
return err("Unknown content topic: " & contentTopic)
103104

@@ -112,12 +113,14 @@ proc getDefaultContentTopics*(): seq[string] =
112113
@[MembershipContentTopic, ProofMetadataContentTopic]
113114

114115
# Helper function for building a logos-messaging subscription filter
115-
proc buildSubscriptionFilter*(cl: CoordinationLayer): seq[tuple[contentTopic: string, handler: string]] =
116+
proc buildSubscriptionFilter*(
117+
cl: CoordinationLayer
118+
): seq[tuple[contentTopic: string, handler: string]] =
116119
## Build a subscription filter for logos-messaging.
117120
## Returns tuples of (contentTopic, handlerName) for documentation.
118121
@[
119122
(cl.spamProtection.getMembershipContentTopic(), "handleMembershipUpdate"),
120-
(cl.spamProtection.getProofMetadataContentTopic(), "handleProofMetadata")
123+
(cl.spamProtection.getProofMetadataContentTopic(), "handleProofMetadata"),
121124
]
122125

123126
# Integration example documentation
@@ -140,8 +143,11 @@ await spamProtection.init()
140143
let coordination = newCoordinationLayer(spamProtection)
141144
142145
# Wire up publish callback to logos-messaging
143-
coordination.setPublishCallback(proc(topic: string, data: seq[byte]) {.async.} =
146+
coordination.setPublishCallback(proc(
147+
topic: string, data: seq[byte]
148+
): Future[Result[void, string]] {.async.} =
144149
await logosMessaging.publish(topic, data)
150+
ok()
145151
)
146152
147153
# Subscribe to RLN topics (uses configured content topics)
@@ -167,20 +173,25 @@ let mixProto = MixProtocol.new(
167173
# Utility for creating a simple publish callback that logs (for testing)
168174
proc createLoggingPublishCallback*(): PublishCallback =
169175
## Create a publish callback that just logs messages (for testing).
170-
proc callback(contentTopic: string, data: seq[byte]): Future[void] {.async, gcsafe.} =
176+
proc callback(
177+
contentTopic: string, data: seq[byte]
178+
): Future[Result[void, string]] {.async, gcsafe.} =
171179
debug "Would publish to coordination layer",
172-
topic = contentTopic,
173-
dataLen = data.len
180+
topic = contentTopic, dataLen = data.len
181+
ok()
182+
174183
return callback
175184

176185
# Utility for printing membership update in human-readable form
177186
proc formatMembershipUpdate*(update: MembershipUpdate): string =
178187
## Format a membership update for logging/display.
179-
let actionStr = case update.action
188+
let actionStr =
189+
case update.action
180190
of MembershipAction.Add: "ADD"
181191
of MembershipAction.Remove: "REMOVE"
182192

183-
result = actionStr & " member at index " & $update.index &
184-
" (idCommitment: " & update.idCommitment[0..7].toHex() & "...)"
193+
result =
194+
actionStr & " member at index " & $update.index & " (idCommitment: " &
195+
update.idCommitment[0 .. 7].toHex() & "...)"
185196

186197
# Note: toHex is imported from types module via spam_protection

src/mix_rln_spam_protection/group_manager.nim

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,14 @@ type
6868
## Group manager that propagates membership via logos-messaging content topics.
6969
## Membership additions and deletions are broadcast to all nodes.
7070
publishCallback: Option[PublishCallback]
71-
membershipByIdCommitment: Table[IDCommitment, MembershipIndex] ## idCommitment -> index (for spam recovery)
71+
membershipByIdCommitment: Table[IDCommitment, MembershipIndex]
72+
## idCommitment -> index (for spam recovery)
7273
membershipByIndex: Table[MembershipIndex, IDCommitment] ## index -> idCommitment
7374
# We track per-user rate limits because the Merkle tree stores rateCommitment = Poseidon(idCommitment, userMessageLimit),
7475
# not the raw userMessageLimit. When serializing tree snapshots, we need the original userMessageLimit to correctly
7576
# recompute rateCommitment on load. Without this, we'd have to assume all members use the same limit.
76-
rateLimitByIdCommitment: Table[IDCommitment, uint64] ## idCommitment -> userMessageLimit
77+
rateLimitByIdCommitment: Table[IDCommitment, uint64]
78+
## idCommitment -> userMessageLimit
7779
nextIndex: MembershipIndex
7880
membershipContentTopic*: string ## Content topic for membership updates
7981

@@ -161,9 +163,7 @@ proc refreshProofCache*(gm: OffchainGroupManager): RlnResult[void] =
161163
return ok()
162164

163165
let cache = gm.rlnInstance.generatePartialProofCache(
164-
gm.credentials.get(),
165-
gm.membershipIndex.get(),
166-
gm.userMessageLimit,
166+
gm.credentials.get(), gm.membershipIndex.get(), gm.userMessageLimit
167167
).valueOr:
168168
gm.partialProofCache = none(PartialProofCache)
169169
return err("Failed to refresh partial proof cache: " & error)
@@ -234,8 +234,7 @@ method generateProof*(
234234
let index = gm.membershipIndex.get()
235235

236236
trace "Generating proof with credentials",
237-
membershipIndex = index,
238-
commitment = creds.idCommitment.toHex()
237+
membershipIndex = index, commitment = creds.idCommitment.toHex()
239238

240239
# Flush tree to ensure internal state is synced
241240
if not flush(gm.rlnInstance.ctx):
@@ -252,8 +251,7 @@ method generateProof*(
252251
return err("Failed to compute expected rate commitment: " & error)
253252

254253
trace "Tree state verification before proof generation",
255-
membershipIndex = index,
256-
commitmentsMatch = treeCommitment == expectedRateCommitment
254+
membershipIndex = index, commitmentsMatch = treeCommitment == expectedRateCommitment
257255

258256
if treeCommitment != expectedRateCommitment:
259257
error "CRITICAL: Tree commitment at index does not match our credentials!",
@@ -270,9 +268,7 @@ method generateProof*(
270268
return err("Failed to get current Merkle root: " & error)
271269

272270
trace "Generating RLN proof",
273-
signalLen = signal.len,
274-
messageId = messageId,
275-
membershipIndex = index
271+
signalLen = signal.len, messageId = messageId, membershipIndex = index
276272

277273
# Happy path: cached partial-proof should be available and valid most of the time.
278274
# On cache miss/staleness, rebuild once and retry. Fall back to full proof generation.
@@ -309,7 +305,8 @@ method generateProof*(
309305
)
310306
usedPartialCache = proofResult.isOk
311307
if proofResult.isErr:
312-
warn "Refreshed partial proof cache could not be used", error = proofResult.error
308+
warn "Refreshed partial proof cache could not be used",
309+
error = proofResult.error
313310

314311
if not usedPartialCache:
315312
proofResult = gm.rlnInstance.generateRlnProofWithWitness(
@@ -429,11 +426,7 @@ proc restoreMemberFromKeystore*(
429426
if not gm.isInitialized:
430427
return err("Group manager not initialized")
431428

432-
let memberLimit =
433-
if userMessageLimit > 0:
434-
userMessageLimit
435-
else:
436-
gm.userMessageLimit
429+
let memberLimit = if userMessageLimit > 0: userMessageLimit else: gm.userMessageLimit
437430

438431
# Compute rate commitment = Poseidon(idCommitment, userMessageLimit)
439432
# This is the actual leaf value stored in the RLN Merkle tree
@@ -512,7 +505,8 @@ method register*(
512505
index: index,
513506
)
514507
let data = update.toBytes()
515-
await gm.publishCallback.get()(gm.membershipContentTopic, data)
508+
(await gm.publishCallback.get()(gm.membershipContentTopic, data)).isOkOr:
509+
warn "Failed to broadcast membership update", error = error
516510

517511
# Call callback
518512
if gm.onRegister.isSome:
@@ -538,7 +532,8 @@ proc registerWithLimit*(
538532
return err("Failed to compute rate commitment: " & error)
539533

540534
let index = gm.nextIndex
541-
trace "Registering member with custom limit", index = index, userMessageLimit = userMessageLimit
535+
trace "Registering member with custom limit",
536+
index = index, userMessageLimit = userMessageLimit
542537
gm.nextIndex += 1
543538

544539
# Insert rateCommitment into RLN tree
@@ -564,13 +559,15 @@ proc registerWithLimit*(
564559
index: index,
565560
)
566561
let data = update.toBytes()
567-
await gm.publishCallback.get()(gm.membershipContentTopic, data)
562+
(await gm.publishCallback.get()(gm.membershipContentTopic, data)).isOkOr:
563+
warn "Failed to broadcast membership update", error = error
568564

569565
# Call callback
570566
if gm.onRegister.isSome:
571567
await gm.onRegister.get()(commitment, index)
572568

573-
debug "Member registered with custom limit", index = index, userMessageLimit = userMessageLimit
569+
debug "Member registered with custom limit",
570+
index = index, userMessageLimit = userMessageLimit
574571
ok(index)
575572

576573
method register*(
@@ -613,9 +610,8 @@ method withdraw*(
613610
return err("Failed to delete member: " & deleteResult.error)
614611

615612
# Get the member's rate limit before deleting (for broadcast)
616-
let memberRateLimit = gm.rateLimitByIdCommitment.getOrDefault(
617-
idCommitment, gm.userMessageLimit
618-
)
613+
let memberRateLimit =
614+
gm.rateLimitByIdCommitment.getOrDefault(idCommitment, gm.userMessageLimit)
619615

620616
# Update local tracking
621617
gm.membershipByIdCommitment.del(idCommitment)
@@ -635,7 +631,8 @@ method withdraw*(
635631
index: index,
636632
)
637633
let data = update.toBytes()
638-
await gm.publishCallback.get()(gm.membershipContentTopic, data)
634+
(await gm.publishCallback.get()(gm.membershipContentTopic, data)).isOkOr:
635+
warn "Failed to broadcast membership update", error = error
639636

640637
# Call callback
641638
if gm.onWithdraw.isSome:
@@ -789,8 +786,8 @@ proc getMemberRateLimit*(
789786
# └─────────────────────────────────────────────────────────────┘
790787

791788
const
792-
SnapshotHeaderSize = 16 # member_count (8) + next_index (8)
793-
SnapshotMemberSize = 48 # commitment (32) + index (8) + userMessageLimit (8)
789+
SnapshotHeaderSize = 16 # member_count (8) + next_index (8)
790+
SnapshotMemberSize = 48 # commitment (32) + index (8) + userMessageLimit (8)
794791

795792
# Note: writeUint64LE and readUint64LE are imported from bytes_utils via types
796793

@@ -821,9 +818,8 @@ proc serializeTreeSnapshot*(gm: OffchainGroupManager): seq[byte] =
821818
offset += Uint64ByteSize
822819

823820
# Write userMessageLimit
824-
let rateLimit = gm.rateLimitByIdCommitment.getOrDefault(
825-
commitment, gm.userMessageLimit
826-
)
821+
let rateLimit =
822+
gm.rateLimitByIdCommitment.getOrDefault(commitment, gm.userMessageLimit)
827823
result.writeUint64LE(offset, rateLimit)
828824
offset += Uint64ByteSize
829825

@@ -849,7 +845,10 @@ proc loadTreeSnapshot*(gm: OffchainGroupManager, data: seq[byte]): RlnResult[voi
849845
if data.len != expectedSize:
850846
error "Snapshot size mismatch",
851847
actual = data.len, expected = expectedSize, memberCount = memberCount
852-
return err("Invalid snapshot: size mismatch (expected " & $expectedSize & ", got " & $data.len & ")")
848+
return err(
849+
"Invalid snapshot: size mismatch (expected " & $expectedSize & ", got " & $data.len &
850+
")"
851+
)
853852

854853
trace "Loading tree snapshot", memberCount = memberCount, nextIndex = nextIndex
855854

@@ -883,7 +882,8 @@ proc loadTreeSnapshot*(gm: OffchainGroupManager, data: seq[byte]): RlnResult[voi
883882
# Insert into RLN Merkle tree
884883
let insertResult = gm.rlnInstance.insertMemberAt(index, rateCommitment)
885884
if insertResult.isErr:
886-
error "Failed to insert member from snapshot", index = index, error = insertResult.error
885+
error "Failed to insert member from snapshot",
886+
index = index, error = insertResult.error
887887
return err("Failed to insert member: " & insertResult.error)
888888

889889
# Update tracking tables - track by idCommitment for spam recovery
@@ -897,7 +897,8 @@ proc loadTreeSnapshot*(gm: OffchainGroupManager, data: seq[byte]): RlnResult[voi
897897
gm.resetRootTrackerOrLog()
898898
gm.refreshProofCacheOrLog()
899899

900-
debug "Tree snapshot loaded successfully", memberCount = memberCount, nextIndex = nextIndex
900+
debug "Tree snapshot loaded successfully",
901+
memberCount = memberCount, nextIndex = nextIndex
901902
ok()
902903

903904
proc saveTreeToFile*(gm: OffchainGroupManager, path: string): RlnResult[void] =
@@ -929,9 +930,7 @@ proc loadTreeFromFile*(gm: OffchainGroupManager, path: string): RlnResult[void]
929930
if not flush(gm.rlnInstance.ctx):
930931
return err("Failed to flush tree after loading")
931932

932-
debug "Tree loaded and flushed",
933-
path = path,
934-
memberCount = gm.membershipByIndex.len
933+
debug "Tree loaded and flushed", path = path, memberCount = gm.membershipByIndex.len
935934

936935
return ok()
937936
except IOError as e:

0 commit comments

Comments
 (0)