logos-delivery/tests/waku_store_sync/test_full_node_sync.nim
darshankabariya 3accb291c9 feat: persist RLN proofs in the archive so synced history stays verifiable
Live messages carry an RLN proof, but archives dropped it at insert
time - so history handed over via store-sync transfer or store resume
arrived proofless and had to be accepted on faith, sidestepping the
spam limiter exactly where it should be load-bearing. A malicious peer
could invent 'history you missed' and a victim would swallow and
re-serve it.

- archive: proof column (sqlite schema v11, postgres v8; nullable, so
  existing rows migrate as a no-op and in-memory archives need no
  migration); insert/select thread message.proof through both drivers;
  the queue driver stores whole messages and needed no change
- transfer and store resume serve proof-bearing messages automatically
  (the wire codecs already carry the field) and both re-verify the
  original author's proof on arrival: the receiving node's FIRST
  verification of a message it missed on relay, not a repeat - same
  work the relay path would have done, deferred to catch-up
- store resume gains the same validator the sync transfer uses, since
  a store node could also serve invented history
- new --store-sync-require-proof flag (default off): the rollout lever;
  once the fleet persists and serves proofs, flipping it makes nodes
  reject proofless synced history when RLN is enabled
- reconciliation untouched: proofs are not part of the message hash,
  so identity, fingerprints and cursors are unchanged

Tested end-to-end: a proof survives full-node -> full-node sync
transfer and the store-query -> resume -> archive trip intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:42:26 +05:30

204 lines
6.0 KiB
Nim

{.used.}
## Integration test for the "store as a startup-only dependency" experiment:
## two full nodes (no store service) wired exactly like `mountStoreSync` —
## reconciliation + transfer sharing one peer manager and the three async
## queues, backed by small in-memory archives — must recover missed
## messages from each other end to end.
import results, testutils/unittests, chronos, chronicles
import
../../logos_delivery/waku/[
node/peer_manager,
waku_core,
waku_core/message/digest,
waku_store_sync/common,
waku_store_sync/reconciliation,
waku_store_sync/transfer,
waku_archive/archive,
waku_archive/driver,
waku_archive/common,
],
../testlib/[wakucore, testasync],
../waku_archive/archive_utils
type FullNode = object
switch: Switch
driver: ArchiveDriver
archive: WakuArchive
recon: SyncReconciliation
transfer: SyncTransfer
peerInfo: RemotePeerInfo
peerManager: PeerManager
proc newFullNode(
msgValidator: Opt[TransferValidator] = Opt.none(TransferValidator)
): Future[FullNode] {.async.} =
## Mirrors the wiring of WakuNode.mountStoreSync: one peer manager and
## three shared channels connect reconciliation and transfer.
let switch = newTestSwitch()
await switch.start()
let driver = newSqliteArchiveDriver()
let archive = newWakuArchive(driver)
let peerManager = PeerManager.new(switch)
let idsChannel = newAsyncQueue[(SyncID, PubsubTopic, ContentTopic)]()
let wantsChannel = newAsyncQueue[PeerId]()
let needsChannel = newAsyncQueue[(PeerId, WakuMessageHash)]()
let recon = (
await SyncReconciliation.new(
pubsubTopics = @[],
contentTopics = @[],
peerManager = peerManager,
wakuArchive = archive,
relayJitter = 0.seconds,
idsRx = idsChannel,
localWantsTx = wantsChannel,
remoteNeedsTx = needsChannel,
)
).valueOr:
raiseAssert error
await recon.start()
switch.mount(recon)
let transfer = SyncTransfer.new(
peerManager = peerManager,
wakuArchive = archive,
idsTx = idsChannel,
localWantsRx = wantsChannel,
remoteNeedsRx = needsChannel,
msgValidator = msgValidator,
)
await transfer.start()
switch.mount(transfer)
return FullNode(
switch: switch,
driver: driver,
archive: archive,
recon: recon,
transfer: transfer,
peerInfo: switch.peerInfo.toRemotePeerInfo(),
peerManager: peerManager,
)
proc stop(node: FullNode) {.async.} =
await node.transfer.stop()
await node.recon.stop()
await node.switch.stop()
proc insertMessage(node: FullNode, msg: WakuMessage) =
## Live-ingest path: relay would archive the message and feed the
## reconciliation storage (subscription_manager archive + sync handlers).
discard node.driver.put(DefaultPubsubTopic, @[msg])
node.recon.messageIngress(DefaultPubsubTopic, msg)
proc hasMessage(node: FullNode, hash: WakuMessageHash): Future[bool] {.async.} =
var query = ArchiveQuery()
query.includeData = true
query.hashes = @[hash]
let response = (await node.archive.findMessages(query)).valueOr:
raiseAssert $error
return response.messages.len > 0
suite "Waku Sync: full node miss recovery":
var nodeA {.threadvar.}: FullNode
var nodeB {.threadvar.}: FullNode
asyncSetup:
nodeA = await newFullNode()
nodeB = await newFullNode()
nodeA.peerManager.addPeer(nodeB.peerInfo)
nodeB.peerManager.addPeer(nodeA.peerInfo)
asyncTeardown:
await nodeA.stop()
await nodeB.stop()
asyncTest "node recovers a missed message from a full-node peer":
let msg = fakeWakuMessage(contentTopic = DefaultContentTopic)
let hash = computeMessageHash(DefaultPubsubTopic, msg)
nodeA.insertMessage(msg)
check not await nodeB.hasMessage(hash)
let res = await nodeB.recon.storeSynchronization(Opt.some(nodeA.peerInfo))
assert res.isOk(), $res.error
# transfer of the missing message happens asynchronously after the
# reconciliation session ends
await sleepAsync(1.seconds)
check await nodeB.hasMessage(hash)
asyncTest "one session converges both nodes to the union":
let msgA = fakeWakuMessage(payload = @[byte 1], contentTopic = DefaultContentTopic)
let msgB = fakeWakuMessage(payload = @[byte 2], contentTopic = DefaultContentTopic)
let hashA = computeMessageHash(DefaultPubsubTopic, msgA)
let hashB = computeMessageHash(DefaultPubsubTopic, msgB)
nodeA.insertMessage(msgA)
nodeB.insertMessage(msgB)
let res = await nodeB.recon.storeSynchronization(Opt.some(nodeA.peerInfo))
assert res.isOk(), $res.error
await sleepAsync(1.seconds)
check:
await nodeA.hasMessage(hashB)
await nodeB.hasMessage(hashA)
asyncTest "synced messages keep the original author's proof":
let proof = @[byte 4, 2, 4, 2]
let msg = fakeWakuMessage(contentTopic = DefaultContentTopic, proof = proof)
let hash = computeMessageHash(DefaultPubsubTopic, msg)
nodeA.insertMessage(msg)
let res = await nodeB.recon.storeSynchronization(Opt.some(nodeA.peerInfo))
assert res.isOk(), $res.error
await sleepAsync(1.seconds)
var query = ArchiveQuery()
query.includeData = true
query.hashes = @[hash]
let response = (await nodeB.archive.findMessages(query)).valueOr:
raiseAssert $error
check:
response.messages.len == 1
response.messages[0].proof == proof
asyncTest "messages failing transfer validation are not archived":
await nodeB.stop()
let rejectAll: TransferValidator = proc(msg: WakuMessage): Future[bool] {.async.} =
return false
nodeB = await newFullNode(msgValidator = Opt.some(rejectAll))
nodeA.peerManager.addPeer(nodeB.peerInfo)
nodeB.peerManager.addPeer(nodeA.peerInfo)
let msg = fakeWakuMessage(contentTopic = DefaultContentTopic)
let hash = computeMessageHash(DefaultPubsubTopic, msg)
nodeA.insertMessage(msg)
let res = await nodeB.recon.storeSynchronization(Opt.some(nodeA.peerInfo))
assert res.isOk(), $res.error
await sleepAsync(1.seconds)
check not await nodeB.hasMessage(hash)