From cd5909fafe914014b4602492586e8c2b48b53349 Mon Sep 17 00:00:00 2001 From: Darshan K <35736874+darshankabariya@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:53:23 +0530 Subject: [PATCH 01/18] chore: first beta release v0.37.0 (#3607) --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc073792c..61e818afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,62 @@ +## v0.37.0 (2025-10-01) + +### Notes + +- Deprecated parameters: + - `tree_path` and `rlnDB` (RLN-related storage paths) + - `--dns-discovery` (fully removed, including dns-discovery-name-server) + - `keepAlive` (deprecated, config updated accordingly) +- Legacy `store` protocol is no longer supported by default. +- Improved sharding configuration: now explicit and shard-specific metrics added. +- Mix nodes are limited to IPv4 addresses only. +- [lightpush legacy](https://github.com/vacp2p/rfc-index/blob/main/waku/standards/core/19/lightpush.md) is being deprecated. Use [lightpush v3](https://github.com/waku-org/specs/blob/master/standards/core/lightpush.md) instead. + +### Features + +- Waku API: create node via API ([#3580](https://github.com/waku-org/nwaku/pull/3580)) ([bc8acf76](https://github.com/waku-org/nwaku/commit/bc8acf76)) +- Waku Sync: full topic support ([#3275](https://github.com/waku-org/nwaku/pull/3275)) ([9327da5a](https://github.com/waku-org/nwaku/commit/9327da5a)) +- Mix PoC implementation ([#3284](https://github.com/waku-org/nwaku/pull/3284)) ([eb7a3d13](https://github.com/waku-org/nwaku/commit/eb7a3d13)) +- Rendezvous: add request interval option ([#3569](https://github.com/waku-org/nwaku/pull/3569)) ([cc7a6406](https://github.com/waku-org/nwaku/commit/cc7a6406)) +- Shard-specific metrics tracking ([#3520](https://github.com/waku-org/nwaku/pull/3520)) ([c3da29fd](https://github.com/waku-org/nwaku/commit/c3da29fd)) +- Libwaku: build Windows DLL for Status-go ([#3460](https://github.com/waku-org/nwaku/pull/3460)) ([5c38a53f](https://github.com/waku-org/nwaku/commit/5c38a53f)) +- RLN: add Stateless RLN support ([#3621](https://github.com/waku-org/nwaku/pull/3621)) +- LOG: Reduce log level of messages from debug to info for better visibility ([#3622](https://github.com/waku-org/nwaku/pull/3622)) + +### Bug Fixes + +- Prevent invalid pubsub topic subscription via Relay REST API ([#3559](https://github.com/waku-org/nwaku/pull/3559)) ([a36601ab](https://github.com/waku-org/nwaku/commit/a36601ab)) +- Fixed node crash when RLN is unregistered ([#3573](https://github.com/waku-org/nwaku/pull/3573)) ([3d0c6279](https://github.com/waku-org/nwaku/commit/3d0c6279)) +- REST: fixed sync protocol issues ([#3503](https://github.com/waku-org/nwaku/pull/3503)) ([393e3cce](https://github.com/waku-org/nwaku/commit/393e3cce)) +- Regex pattern fix for `username:password@` in URLs ([#3517](https://github.com/waku-org/nwaku/pull/3517)) ([89a3f735](https://github.com/waku-org/nwaku/commit/89a3f735)) +- Sharding: applied modulus fix ([#3530](https://github.com/waku-org/nwaku/pull/3530)) ([f68d7999](https://github.com/waku-org/nwaku/commit/f68d7999)) +- Metrics: switched to counter instead of gauge ([#3355](https://github.com/waku-org/nwaku/pull/3355)) ([a27eec90](https://github.com/waku-org/nwaku/commit/a27eec90)) +- Fixed lightpush metrics and diagnostics ([#3486](https://github.com/waku-org/nwaku/pull/3486)) ([0ed3fc80](https://github.com/waku-org/nwaku/commit/0ed3fc80)) +- Misc sync, dashboard, and CI fixes ([#3434](https://github.com/waku-org/nwaku/pull/3434), [#3508](https://github.com/waku-org/nwaku/pull/3508), [#3464](https://github.com/waku-org/nwaku/pull/3464)) +- Raise log level of numerous operational messages from debug to info for better visibility ([#3622](https://github.com/waku-org/nwaku/pull/3622)) + +### Changes + +- Enable peer-exchange by default ([#3557](https://github.com/waku-org/nwaku/pull/3557)) ([7df526f8](https://github.com/waku-org/nwaku/commit/7df526f8)) +- Refactor peer-exchange client and service implementations ([#3523](https://github.com/waku-org/nwaku/pull/3523)) ([4379f9ec](https://github.com/waku-org/nwaku/commit/4379f9ec)) +- Updated rendezvous to use callback-based shard/capability updates ([#3558](https://github.com/waku-org/nwaku/pull/3558)) ([028bf297](https://github.com/waku-org/nwaku/commit/028bf297)) +- Config updates and explicit sharding setup ([#3468](https://github.com/waku-org/nwaku/pull/3468)) ([994d485b](https://github.com/waku-org/nwaku/commit/994d485b)) +- Bumped libp2p to v1.13.0 ([#3574](https://github.com/waku-org/nwaku/pull/3574)) ([b1616e55](https://github.com/waku-org/nwaku/commit/b1616e55)) +- Removed legacy dependencies (e.g., libpcre in Docker builds) ([#3552](https://github.com/waku-org/nwaku/pull/3552)) ([4db4f830](https://github.com/waku-org/nwaku/commit/4db4f830)) +- Benchmarks for RLN proof generation & verification ([#3567](https://github.com/waku-org/nwaku/pull/3567)) ([794c3a85](https://github.com/waku-org/nwaku/commit/794c3a85)) +- Various CI/CD & infra updates ([#3515](https://github.com/waku-org/nwaku/pull/3515), [#3505](https://github.com/waku-org/nwaku/pull/3505)) + +### This release supports the following [libp2p protocols](https://docs.libp2p.io/concepts/protocols/): + +| Protocol | Spec status | Protocol id | +| ---: | :---: | :--- | +| [`11/WAKU2-RELAY`](https://github.com/vacp2p/rfc-index/blob/main/waku/standards/core/11/relay.md) | `stable` | `/vac/waku/relay/2.0.0` | +| [`12/WAKU2-FILTER`](https://github.com/vacp2p/rfc-index/blob/main/waku/standards/core/12/filter.md) | `draft` | `/vac/waku/filter/2.0.0-beta1`
`/vac/waku/filter-subscribe/2.0.0-beta1`
`/vac/waku/filter-push/2.0.0-beta1` | +| [`13/WAKU2-STORE`](https://github.com/vacp2p/rfc-index/blob/main/waku/standards/core/13/store.md) | `draft` | `/vac/waku/store/2.0.0-beta4` | +| [`19/WAKU2-LIGHTPUSH`](https://github.com/vacp2p/rfc-index/blob/main/waku/standards/core/19/lightpush.md) | `draft` | `/vac/waku/lightpush/2.0.0-beta1` | +| [`WAKU2-LIGHTPUSH v3`](https://github.com/waku-org/specs/blob/master/standards/core/lightpush.md) | `draft` | `/vac/waku/lightpush/3.0.0` | +| [`66/WAKU2-METADATA`](https://github.com/waku-org/specs/blob/master/standards/core/metadata.md) | `raw` | `/vac/waku/metadata/1.0.0` | +| [`WAKU-SYNC`](https://github.com/waku-org/specs/blob/master/standards/core/sync.md) | `draft` | `/vac/waku/sync/1.0.0` | + ## v0.36.0 (2025-06-20) ### Notes From adeb1a928ec8943a02d5cbd3927fc178ba2f3686 Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Thu, 20 Nov 2025 08:44:15 +0100 Subject: [PATCH 02/18] fix: wakucanary now fails correctly when ping fails (#3595) * wakucanary add some more detail if exception Co-authored-by: MorganaFuture --- apps/wakucanary/wakucanary.nim | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/wakucanary/wakucanary.nim b/apps/wakucanary/wakucanary.nim index bcff9653e..6e02c2a8f 100644 --- a/apps/wakucanary/wakucanary.nim +++ b/apps/wakucanary/wakucanary.nim @@ -143,16 +143,18 @@ proc areProtocolsSupported( proc pingNode( node: WakuNode, peerInfo: RemotePeerInfo -): Future[void] {.async, gcsafe.} = +): Future[bool] {.async, gcsafe.} = try: let conn = await node.switch.dial(peerInfo.peerId, peerInfo.addrs, PingCodec) let pingDelay = await node.libp2pPing.ping(conn) info "Peer response time (ms)", peerId = peerInfo.peerId, ping = pingDelay.millis + return true except CatchableError: var msg = getCurrentExceptionMsg() if msg == "Future operation cancelled!": msg = "timedout" error "Failed to ping the peer", peer = peerInfo, err = msg + return false proc main(rng: ref HmacDrbgContext): Future[int] {.async.} = let conf: WakuCanaryConf = WakuCanaryConf.load() @@ -268,8 +270,13 @@ proc main(rng: ref HmacDrbgContext): Future[int] {.async.} = let lp2pPeerStore = node.switch.peerStore let conStatus = node.peerManager.switch.peerStore[ConnectionBook][peer.peerId] + var pingSuccess = true if conf.ping: - discard await pingFut + try: + pingSuccess = await pingFut + except CatchableError as exc: + pingSuccess = false + error "Ping operation failed or timed out", error = exc.msg if conStatus in [Connected, CanConnect]: let nodeProtocols = lp2pPeerStore[ProtoBook][peer.peerId] @@ -278,6 +285,11 @@ proc main(rng: ref HmacDrbgContext): Future[int] {.async.} = error "Not all protocols are supported", expected = conf.protocols, supported = nodeProtocols quit(QuitFailure) + + # Check ping result if ping was enabled + if conf.ping and not pingSuccess: + error "Node is reachable and supports protocols but ping failed - connection may be unstable" + quit(QuitFailure) elif conStatus == CannotConnect: error "Could not connect", peerId = peer.peerId quit(QuitFailure) From e54851d9d63fd9e7e5e70844364a4464c4dba8f4 Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:12:16 +0100 Subject: [PATCH 03/18] fix: admin API peer shards field from metadata protocol (#3594) * fix: admin API peer shards field from metadata protocol Store and return peer shard info from metadata protocol exchange instead of only checking ENR records. * peer_manager set shard info and extend rest test to validate it Co-authored-by: MorganaFuture --- tests/wakunode_rest/test_rest_admin.nim | 14 +++++++++++++- waku/node/peer_manager/peer_manager.nim | 5 +++++ waku/node/peer_manager/waku_peer_store.nim | 8 ++++++++ waku/waku_core/peers.nim | 12 +++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/wakunode_rest/test_rest_admin.nim b/tests/wakunode_rest/test_rest_admin.nim index 6de886f74..ef82b8dfc 100644 --- a/tests/wakunode_rest/test_rest_admin.nim +++ b/tests/wakunode_rest/test_rest_admin.nim @@ -65,7 +65,7 @@ suite "Waku v2 Rest API - Admin": ): Future[void] {.async, gcsafe.} = await sleepAsync(0.milliseconds) - let shard = RelayShard(clusterId: clusterId, shardId: 0) + let shard = RelayShard(clusterId: clusterId, shardId: 5) node1.subscribe((kind: PubsubSub, topic: $shard), simpleHandler).isOkOr: assert false, "Failed to subscribe to topic: " & $error node2.subscribe((kind: PubsubSub, topic: $shard), simpleHandler).isOkOr: @@ -212,6 +212,18 @@ suite "Waku v2 Rest API - Admin": let conn2 = await node1.peerManager.connectPeer(peerInfo2) let conn3 = await node1.peerManager.connectPeer(peerInfo3) + var count = 0 + while count < 20: + ## Wait ~1s at most for the peer store to update shard info + let getRes = await client.getPeers() + if getRes.data.allIt(it.shards == @[5.uint16]): + break + + count.inc() + await sleepAsync(50.milliseconds) + + assert count < 20, "Timeout waiting for shards to be updated in peer store" + # Check successful connections check: conn2 == true diff --git a/waku/node/peer_manager/peer_manager.nim b/waku/node/peer_manager/peer_manager.nim index 72b526aca..1abcc1ac0 100644 --- a/waku/node/peer_manager/peer_manager.nim +++ b/waku/node/peer_manager/peer_manager.nim @@ -658,6 +658,11 @@ proc onPeerMetadata(pm: PeerManager, peerId: PeerId) {.async.} = $clusterId break guardClauses + # Store the shard information from metadata in the peer store + if pm.switch.peerStore.peerExists(peerId): + let shards = metadata.shards.mapIt(it.uint16) + pm.switch.peerStore.setShardInfo(peerId, shards) + return info "disconnecting from peer", peerId = peerId, reason = reason diff --git a/waku/node/peer_manager/waku_peer_store.nim b/waku/node/peer_manager/waku_peer_store.nim index 0098c1687..c9e2d4817 100644 --- a/waku/node/peer_manager/waku_peer_store.nim +++ b/waku/node/peer_manager/waku_peer_store.nim @@ -39,6 +39,9 @@ type # Keeps track of the ENR (Ethereum Node Record) of a peer ENRBook* = ref object of PeerBook[enr.Record] + # Keeps track of peer shards + ShardBook* = ref object of PeerBook[seq[uint16]] + proc getPeer*(peerStore: PeerStore, peerId: PeerId): RemotePeerInfo = let addresses = if peerStore[LastSeenBook][peerId].isSome(): @@ -55,6 +58,7 @@ proc getPeer*(peerStore: PeerStore, peerId: PeerId): RemotePeerInfo = else: none(enr.Record), protocols: peerStore[ProtoBook][peerId], + shards: peerStore[ShardBook][peerId], agent: peerStore[AgentBook][peerId], protoVersion: peerStore[ProtoVersionBook][peerId], publicKey: peerStore[KeyBook][peerId], @@ -76,6 +80,7 @@ proc peers*(peerStore: PeerStore): seq[RemotePeerInfo] = toSeq(peerStore[AddressBook].book.keys()), toSeq(peerStore[ProtoBook].book.keys()), toSeq(peerStore[KeyBook].book.keys()), + toSeq(peerStore[ShardBook].book.keys()), ) .toHashSet() @@ -127,6 +132,9 @@ proc addPeer*(peerStore: PeerStore, peer: RemotePeerInfo, origin = UnknownOrigin if peer.enr.isSome(): peerStore[ENRBook][peer.peerId] = peer.enr.get() +proc setShardInfo*(peerStore: PeerStore, peerId: PeerID, shards: seq[uint16]) = + peerStore[ShardBook][peerId] = shards + proc peers*(peerStore: PeerStore, proto: string): seq[RemotePeerInfo] = peerStore.peers().filterIt(it.protocols.contains(proto)) diff --git a/waku/waku_core/peers.nim b/waku/waku_core/peers.nim index 5591699c6..76ff29aa0 100644 --- a/waku/waku_core/peers.nim +++ b/waku/waku_core/peers.nim @@ -48,6 +48,7 @@ type RemotePeerInfo* = ref object addrs*: seq[MultiAddress] enr*: Option[enr.Record] protocols*: seq[string] + shards*: seq[uint16] agent*: string protoVersion*: string @@ -73,6 +74,7 @@ proc init*( addrs: seq[MultiAddress] = @[], enr: Option[enr.Record] = none(enr.Record), protocols: seq[string] = @[], + shards: seq[uint16] = @[], publicKey: crypto.PublicKey = crypto.PublicKey(), agent: string = "", protoVersion: string = "", @@ -88,6 +90,7 @@ proc init*( addrs: addrs, enr: enr, protocols: protocols, + shards: shards, publicKey: publicKey, agent: agent, protoVersion: protoVersion, @@ -105,9 +108,12 @@ proc init*( addrs: seq[MultiAddress] = @[], enr: Option[enr.Record] = none(enr.Record), protocols: seq[string] = @[], + shards: seq[uint16] = @[], ): T {.raises: [Defect, ResultError[cstring], LPError].} = let peerId = PeerID.init(peerId).tryGet() - RemotePeerInfo(peerId: peerId, addrs: addrs, enr: enr, protocols: protocols) + RemotePeerInfo( + peerId: peerId, addrs: addrs, enr: enr, protocols: protocols, shards: shards + ) ## Parse @@ -326,6 +332,7 @@ converter toRemotePeerInfo*(peerInfo: PeerInfo): RemotePeerInfo = addrs: peerInfo.listenAddrs, enr: none(enr.Record), protocols: peerInfo.protocols, + shards: @[], agent: peerInfo.agentVersion, protoVersion: peerInfo.protoVersion, publicKey: peerInfo.publicKey, @@ -361,6 +368,9 @@ proc getAgent*(peer: RemotePeerInfo): string = return peer.agent proc getShards*(peer: RemotePeerInfo): seq[uint16] = + if peer.shards.len > 0: + return peer.shards + if peer.enr.isNone(): return @[] From 31e1a81552d72cd07133754e1953639da49ec954 Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:40:08 +0100 Subject: [PATCH 04/18] nix: add wakucanary Flake package (#3599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jakub Sokołowski Co-authored-by: Jakub Sokołowski --- Makefile | 1 - flake.lock | 32 +++++++++++++++++++++++++++----- flake.nix | 17 ++++++++++++----- nix/atlas.nix | 12 ------------ nix/checksums.nix | 2 +- nix/default.nix | 28 ++++++++++++---------------- nix/nimble.nix | 4 ++-- nix/sat.nix | 5 +++-- 8 files changed, 57 insertions(+), 44 deletions(-) delete mode 100644 nix/atlas.nix diff --git a/Makefile b/Makefile index 37341792c..029313c99 100644 --- a/Makefile +++ b/Makefile @@ -543,4 +543,3 @@ release-notes: sed -E 's@#([0-9]+)@[#\1](https://github.com/waku-org/nwaku/issues/\1)@g' # I could not get the tool to replace issue ids with links, so using sed for now, # asked here: https://github.com/bvieira/sv4git/discussions/101 - diff --git a/flake.lock b/flake.lock index 359ae2579..0700e6a43 100644 --- a/flake.lock +++ b/flake.lock @@ -22,24 +22,46 @@ "zerokit": "zerokit" } }, - "zerokit": { + "rust-overlay": { "inputs": { "nixpkgs": [ + "zerokit", "nixpkgs" ] }, "locked": { - "lastModified": 1743756626, - "narHash": "sha256-SvhfEl0bJcRsCd79jYvZbxQecGV2aT+TXjJ57WVv7Aw=", + "lastModified": 1748399823, + "narHash": "sha256-kahD8D5hOXOsGbNdoLLnqCL887cjHkx98Izc37nDjlA=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "d68a69dc71bc19beb3479800392112c2f6218159", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "zerokit": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1749115386, + "narHash": "sha256-UexIE2D7zr6aRajwnKongXwCZCeRZDXOL0kfjhqUFSU=", "owner": "vacp2p", "repo": "zerokit", - "rev": "c60e0c33fc6350a4b1c20e6b6727c44317129582", + "rev": "dc0b31752c91e7b4fefc441cfa6a8210ad7dba7b", "type": "github" }, "original": { "owner": "vacp2p", "repo": "zerokit", - "rev": "c60e0c33fc6350a4b1c20e6b6727c44317129582", + "rev": "dc0b31752c91e7b4fefc441cfa6a8210ad7dba7b", "type": "github" } } diff --git a/flake.nix b/flake.nix index 760f49337..72eaebef1 100644 --- a/flake.nix +++ b/flake.nix @@ -9,7 +9,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs?rev=f44bd8ca21e026135061a0a57dcf3d0775b67a49"; zerokit = { - url = "github:vacp2p/zerokit?rev=c60e0c33fc6350a4b1c20e6b6727c44317129582"; + url = "github:vacp2p/zerokit?rev=dc0b31752c91e7b4fefc441cfa6a8210ad7dba7b"; inputs.nixpkgs.follows = "nixpkgs"; }; }; @@ -49,11 +49,18 @@ libwaku-android-arm64 = pkgs.callPackage ./nix/default.nix { inherit stableSystems; src = self; - targets = ["libwaku-android-arm64"]; - androidArch = "aarch64-linux-android"; + targets = ["libwaku-android-arm64"]; abidir = "arm64-v8a"; - zerokitPkg = zerokit.packages.${system}.zerokit-android-arm64; + zerokitRln = zerokit.packages.${system}.rln-android-arm64; }; + + wakucanary = pkgs.callPackage ./nix/default.nix { + inherit stableSystems; + src = self; + targets = ["wakucanary"]; + zerokitRln = zerokit.packages.${system}.rln; + }; + default = libwaku-android-arm64; }); @@ -61,4 +68,4 @@ default = pkgsFor.${system}.callPackage ./nix/shell.nix {}; }); }; -} \ No newline at end of file +} diff --git a/nix/atlas.nix b/nix/atlas.nix deleted file mode 100644 index 43336e07a..000000000 --- a/nix/atlas.nix +++ /dev/null @@ -1,12 +0,0 @@ -{ pkgs ? import { } }: - -let - tools = pkgs.callPackage ./tools.nix {}; - sourceFile = ../vendor/nimbus-build-system/vendor/Nim/koch.nim; -in pkgs.fetchFromGitHub { - owner = "nim-lang"; - repo = "atlas"; - rev = tools.findKeyValue "^ +AtlasStableCommit = \"([a-f0-9]+)\"$" sourceFile; - # WARNING: Requires manual updates when Nim compiler version changes. - hash = "sha256-G1TZdgbRPSgxXZ3VsBP2+XFCLHXVb3an65MuQx67o/k="; -} \ No newline at end of file diff --git a/nix/checksums.nix b/nix/checksums.nix index d79345d24..510f2b41a 100644 --- a/nix/checksums.nix +++ b/nix/checksums.nix @@ -6,7 +6,7 @@ let in pkgs.fetchFromGitHub { owner = "nim-lang"; repo = "checksums"; - rev = tools.findKeyValue "^ +ChecksumsStableCommit = \"([a-f0-9]+)\"$" sourceFile; + rev = tools.findKeyValue "^ +ChecksumsStableCommit = \"([a-f0-9]+)\".*$" sourceFile; # WARNING: Requires manual updates when Nim compiler version changes. hash = "sha256-Bm5iJoT2kAvcTexiLMFBa9oU5gf7d4rWjo3OiN7obWQ="; } diff --git a/nix/default.nix b/nix/default.nix index 29eec844d..d78f9935f 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -9,9 +9,8 @@ stableSystems ? [ "x86_64-linux" "aarch64-linux" ], - androidArch, - abidir, - zerokitPkg, + abidir ? null, + zerokitRln, }: assert pkgs.lib.assertMsg ((src.submodules or true) == true) @@ -51,7 +50,7 @@ in stdenv.mkDerivation rec { cmake which lsb-release - zerokitPkg + zerokitRln nim-unwrapped-2_0 fakeGit fakeCargo @@ -84,27 +83,24 @@ in stdenv.mkDerivation rec { pushd vendor/nimbus-build-system/vendor/Nim mkdir dist cp -r ${callPackage ./nimble.nix {}} dist/nimble - chmod 777 -R dist/nimble - mkdir -p dist/nimble/dist - cp -r ${callPackage ./checksums.nix {}} dist/checksums # need both - cp -r ${callPackage ./checksums.nix {}} dist/nimble/dist/checksums - cp -r ${callPackage ./atlas.nix {}} dist/atlas - chmod 777 -R dist/atlas - mkdir dist/atlas/dist - cp -r ${callPackage ./sat.nix {}} dist/nimble/dist/sat - cp -r ${callPackage ./sat.nix {}} dist/atlas/dist/sat + cp -r ${callPackage ./checksums.nix {}} dist/checksums cp -r ${callPackage ./csources.nix {}} csources_v2 chmod 777 -R dist/nimble csources_v2 popd - mkdir -p vendor/zerokit/target/${androidArch}/release - cp ${zerokitPkg}/librln.so vendor/zerokit/target/${androidArch}/release/ + cp -r ${zerokitRln}/target vendor/zerokit/ + find vendor/zerokit/target + # FIXME + cp vendor/zerokit/target/*/release/librln.a librln_v${zerokitRln.version}.a ''; - installPhase = '' + installPhase = if abidir != null then '' mkdir -p $out/jni cp -r ./build/android/${abidir}/* $out/jni/ echo '${androidManifest}' > $out/jni/AndroidManifest.xml cd $out && zip -r libwaku.aar * + '' else '' + mkdir -p $out/bin + cp -r build/* $out/bin ''; meta = with pkgs.lib; { diff --git a/nix/nimble.nix b/nix/nimble.nix index 5bd7b0f32..f9d87da6d 100644 --- a/nix/nimble.nix +++ b/nix/nimble.nix @@ -6,7 +6,7 @@ let in pkgs.fetchFromGitHub { owner = "nim-lang"; repo = "nimble"; - rev = tools.findKeyValue "^ +NimbleStableCommit = \"([a-f0-9]+)\".+" sourceFile; + rev = tools.findKeyValue "^ +NimbleStableCommit = \"([a-f0-9]+)\".*$" sourceFile; # WARNING: Requires manual updates when Nim compiler version changes. hash = "sha256-MVHf19UbOWk8Zba2scj06PxdYYOJA6OXrVyDQ9Ku6Us="; -} \ No newline at end of file +} diff --git a/nix/sat.nix b/nix/sat.nix index 31f264468..92db58a2e 100644 --- a/nix/sat.nix +++ b/nix/sat.nix @@ -6,7 +6,8 @@ let in pkgs.fetchFromGitHub { owner = "nim-lang"; repo = "sat"; - rev = tools.findKeyValue "^ +SatStableCommit = \"([a-f0-9]+)\"$" sourceFile; + rev = tools.findKeyValue "^ +SatStableCommit = \"([a-f0-9]+)\".*$" sourceFile; + # WARNING: Requires manual updates when Nim compiler version changes. # WARNING: Requires manual updates when Nim compiler version changes. hash = "sha256-JFrrSV+mehG0gP7NiQ8hYthL0cjh44HNbXfuxQNhq7c="; -} \ No newline at end of file +} From b0cd75f4cb7e98d3f5b74962f12469d6012b0a57 Mon Sep 17 00:00:00 2001 From: Prem Chaitanya Prathi Date: Fri, 21 Nov 2025 23:15:12 +0530 Subject: [PATCH 05/18] feat: update rendezvous to broadcast and discover WakuPeerRecords (#3617) * update rendezvous to work with WakuPeeRecord and use libp2p updated version * split rendezvous client and service implementation * mount rendezvous client by default --- Dockerfile.lightpushWithMix.compile | 2 +- apps/chat2mix/chat2mix.nim | 18 +- .../lightpush_mix/lightpush_publisher_mix.nim | 60 ++-- .../lightpush_publisher_mix_metrics.nim | 3 + simulations/mixnet/run_chat_mix.sh | 3 +- simulations/mixnet/run_chat_mix1.sh | 3 +- tests/test_waku_rendezvous.nim | 36 ++- tests/waku_discv5/test_waku_discv5.nim | 6 +- vendor/nim-libp2p | 2 +- waku.nimble | 2 +- waku/common/callbacks.nim | 4 +- waku/factory/node_factory.nim | 23 +- waku/factory/waku_conf.nim | 3 +- waku/node/peer_manager/waku_peer_store.nim | 19 +- waku/node/waku_node.nim | 43 ++- waku/waku_core/codecs.nim | 1 + waku/waku_core/peers.nim | 4 + waku/waku_lightpush/client.nim | 12 +- waku/waku_mix/protocol.nim | 79 +++-- waku/waku_rendezvous/client.nim | 142 ++++++++ waku/waku_rendezvous/common.nim | 8 + waku/waku_rendezvous/protocol.nim | 306 +++++++----------- waku/waku_rendezvous/waku_peer_record.nim | 74 +++++ 23 files changed, 564 insertions(+), 289 deletions(-) create mode 100644 waku/waku_rendezvous/client.nim create mode 100644 waku/waku_rendezvous/waku_peer_record.nim diff --git a/Dockerfile.lightpushWithMix.compile b/Dockerfile.lightpushWithMix.compile index 381ee60ef..8006ec50b 100644 --- a/Dockerfile.lightpushWithMix.compile +++ b/Dockerfile.lightpushWithMix.compile @@ -1,5 +1,5 @@ # BUILD NIM APP ---------------------------------------------------------------- -FROM rust:1.81.0-alpine3.19 AS nim-build +FROM rustlang/rust:nightly-alpine3.19 AS nim-build ARG NIMFLAGS ARG MAKE_TARGET=lightpushwithmix diff --git a/apps/chat2mix/chat2mix.nim b/apps/chat2mix/chat2mix.nim index 5979e2936..3fdd7bc9c 100644 --- a/apps/chat2mix/chat2mix.nim +++ b/apps/chat2mix/chat2mix.nim @@ -124,7 +124,7 @@ proc encode*(message: Chat2Message): ProtoBuffer = return serialised -proc toString*(message: Chat2Message): string = +proc `$`*(message: Chat2Message): string = # Get message date and timestamp in local time let time = message.timestamp.fromUnix().local().format("'<'MMM' 'dd,' 'HH:mm'>'") @@ -331,13 +331,14 @@ proc maintainSubscription( const maxFailedServiceNodeSwitches = 10 var noFailedSubscribes = 0 var noFailedServiceNodeSwitches = 0 - const RetryWaitMs = 2.seconds # Quick retry interval - const SubscriptionMaintenanceMs = 30.seconds # Subscription maintenance interval + # Use chronos.Duration explicitly to avoid mismatch with std/times.Duration + let RetryWait = chronos.seconds(2) # Quick retry interval + let SubscriptionMaintenance = chronos.seconds(30) # Subscription maintenance interval while true: info "maintaining subscription at", peer = constructMultiaddrStr(actualFilterPeer) # First use filter-ping to check if we have an active subscription let pingErr = (await wakuNode.wakuFilterClient.ping(actualFilterPeer)).errorOr: - await sleepAsync(SubscriptionMaintenanceMs) + await sleepAsync(SubscriptionMaintenance) info "subscription is live." continue @@ -350,7 +351,7 @@ proc maintainSubscription( some(filterPubsubTopic), filterContentTopic, actualFilterPeer ) ).errorOr: - await sleepAsync(SubscriptionMaintenanceMs) + await sleepAsync(SubscriptionMaintenance) if noFailedSubscribes > 0: noFailedSubscribes -= 1 notice "subscribe request successful." @@ -365,7 +366,7 @@ proc maintainSubscription( # wakunode.peerManager.peerStore.delete(actualFilterPeer) if noFailedSubscribes < maxFailedSubscribes: - await sleepAsync(RetryWaitMs) # Wait a bit before retrying + await sleepAsync(RetryWait) # Wait a bit before retrying elif not preventPeerSwitch: # try again with new peer without delay let actualFilterPeer = selectRandomServicePeer( @@ -380,7 +381,7 @@ proc maintainSubscription( noFailedSubscribes = 0 else: - await sleepAsync(SubscriptionMaintenanceMs) + await sleepAsync(SubscriptionMaintenance) {.pop.} # @TODO confutils.nim(775, 17) Error: can raise an unlisted exception: ref IOError @@ -450,6 +451,8 @@ proc processInput(rfd: AsyncFD, rng: ref HmacDrbgContext) {.async.} = (await node.mountMix(conf.clusterId, mixPrivKey, conf.mixnodes)).isOkOr: error "failed to mount waku mix protocol: ", error = $error quit(QuitFailure) + await node.mountRendezvousClient(conf.clusterId) + await node.start() node.peerManager.start() @@ -587,7 +590,6 @@ proc processInput(rfd: AsyncFD, rng: ref HmacDrbgContext) {.async.} = error "Couldn't find any service peer" quit(QuitFailure) - #await mountLegacyLightPush(node) node.peerManager.addServicePeer(servicePeerInfo, WakuLightpushCodec) node.peerManager.addServicePeer(servicePeerInfo, WakuPeerExchangeCodec) diff --git a/examples/lightpush_mix/lightpush_publisher_mix.nim b/examples/lightpush_mix/lightpush_publisher_mix.nim index 1e26daa9b..bb4bb4c4e 100644 --- a/examples/lightpush_mix/lightpush_publisher_mix.nim +++ b/examples/lightpush_mix/lightpush_publisher_mix.nim @@ -51,7 +51,6 @@ proc splitPeerIdAndAddr(maddr: string): (string, string) = proc setupAndPublish(rng: ref HmacDrbgContext, conf: LightPushMixConf) {.async.} = # use notice to filter all waku messaging setupLog(logging.LogLevel.DEBUG, logging.LogFormat.TEXT) - notice "starting publisher", wakuPort = conf.port let @@ -114,17 +113,8 @@ proc setupAndPublish(rng: ref HmacDrbgContext, conf: LightPushMixConf) {.async.} let dPeerId = PeerId.init(destPeerId).valueOr: error "Failed to initialize PeerId", error = error return - var conn: Connection - if not conf.mixDisabled: - conn = node.wakuMix.toConnection( - MixDestination.init(dPeerId, pxPeerInfo.addrs[0]), # destination lightpush peer - WakuLightPushCodec, # protocol codec which will be used over the mix connection - MixParameters(expectReply: Opt.some(true), numSurbs: Opt.some(byte(1))), - # mix parameters indicating we expect a single reply - ).valueOr: - error "failed to create mix connection", error = error - return + await node.mountRendezvousClient(clusterId) await node.start() node.peerManager.start() node.startPeerExchangeLoop() @@ -145,20 +135,26 @@ proc setupAndPublish(rng: ref HmacDrbgContext, conf: LightPushMixConf) {.async.} var i = 0 while i < conf.numMsgs: + var conn: Connection if conf.mixDisabled: let connOpt = await node.peerManager.dialPeer(dPeerId, WakuLightPushCodec) if connOpt.isNone(): error "failed to dial peer with WakuLightPushCodec", target_peer_id = dPeerId return conn = connOpt.get() + else: + conn = node.wakuMix.toConnection( + MixDestination.init(dPeerId, pxPeerInfo.addrs[0]), # destination lightpush peer + WakuLightPushCodec, # protocol codec which will be used over the mix connection + MixParameters(expectReply: Opt.some(true), numSurbs: Opt.some(byte(1))), + # mix parameters indicating we expect a single reply + ).valueOr: + error "failed to create mix connection", error = error + return i = i + 1 let text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam venenatis magna ut tortor faucibus, in vestibulum nibh commodo. Aenean eget vestibulum augue. Nullam suscipit urna non nunc efficitur, at iaculis nisl consequat. Mauris quis ultrices elit. Suspendisse lobortis odio vitae laoreet facilisis. Cras ornare sem felis, at vulputate magna aliquam ac. Duis quis est ultricies, euismod nulla ac, interdum dui. Maecenas sit amet est vitae enim commodo gravida. Proin vitae elit nulla. Donec tempor dolor lectus, in faucibus velit elementum quis. Donec non mauris eu nibh faucibus cursus ut egestas dolor. Aliquam venenatis ligula id velit pulvinar malesuada. Vestibulum scelerisque, justo non porta gravida, nulla justo tempor purus, at sollicitudin erat erat vel libero. - Fusce nec eros eu metus tristique aliquet. Sed ut magna sagittis, vulputate diam sit amet, aliquam magna. Aenean sollicitudin velit lacus, eu ultrices magna semper at. Integer vitae felis ligula. In a eros nec risus condimentum tincidunt fermentum sit amet ex. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nullam vitae justo maximus, fringilla tellus nec, rutrum purus. Etiam efficitur nisi dapibus euismod vestibulum. Phasellus at felis elementum, tristique nulla ac, consectetur neque. - Maecenas hendrerit nibh eget velit rutrum, in ornare mauris molestie. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Praesent dignissim efficitur eros, sit amet rutrum justo mattis a. Fusce mollis neque at erat placerat bibendum. Ut fringilla fringilla orci, ut fringilla metus fermentum vel. In hac habitasse platea dictumst. Donec hendrerit porttitor odio. Suspendisse ornare sollicitudin mauris, sodales pulvinar velit finibus vel. Fusce id pulvinar neque. Suspendisse eget tincidunt sapien, ac accumsan turpis. - Curabitur cursus tincidunt leo at aliquet. Nunc dapibus quam id venenatis varius. Aenean eget augue vel velit dapibus aliquam. Nulla facilisi. Curabitur cursus, turpis vel congue volutpat, tellus eros cursus lacus, eu fringilla turpis orci non ipsum. In hac habitasse platea dictumst. Nulla aliquam nisl a nunc placerat, eget dignissim felis pulvinar. Fusce sed porta mauris. Donec sodales arcu in nisl sodales, quis posuere massa ultricies. Nam feugiat massa eget felis ultricies finibus. Nunc magna nulla, interdum a elit vel, egestas efficitur urna. Ut posuere tincidunt odio in maximus. Sed at dignissim est. - Morbi accumsan elementum ligula ut fringilla. Praesent in ex metus. Phasellus urna est, tempus sit amet elementum vitae, sollicitudin vel ipsum. Fusce hendrerit eleifend dignissim. Maecenas tempor dapibus dui quis laoreet. Cras tincidunt sed ipsum sed pellentesque. Proin ut tellus nec ipsum varius interdum. Curabitur id velit ligula. Etiam sapien nulla, cursus sodales orci eu, porta lobortis nunc. Nunc at dapibus velit. Nulla et nunc vehicula, condimentum erat quis, elementum dolor. Quisque eu metus fermentum, vestibulum tellus at, sollicitudin odio. Ut vel neque justo. - Praesent porta porta velit, vel porttitor sem. Donec sagittis at nulla venenatis iaculis. Nullam vel eleifend felis. Nullam a pellentesque lectus. Aliquam tincidunt semper dui sed bibendum. Donec hendrerit, urna et cursus dictum, neque neque convallis magna, id condimentum sem urna quis massa. Fusce non quam vulputate, fermentum mauris at, malesuada ipsum. Mauris id pellentesque libero. Donec vel erat ullamcorper, dapibus quam id, imperdiet urna. Praesent sed ligula ut est pellentesque pharetra quis et diam. Ut placerat lorem eget mi fermentum aliquet. + Fusce nec eros eu metus tristique aliquet. This is message #""" & $i & """ sent from a publisher using mix. End of transmission.""" let message = WakuMessage( @@ -168,25 +164,31 @@ proc setupAndPublish(rng: ref HmacDrbgContext, conf: LightPushMixConf) {.async.} timestamp: getNowInNanosecondTime(), ) # current timestamp - let res = await node.wakuLightpushClient.publishWithConn( - LightpushPubsubTopic, message, conn, dPeerId - ) + let startTime = getNowInNanosecondTime() - if res.isOk(): - lp_mix_success.inc() - notice "published message", - text = text, - timestamp = message.timestamp, - psTopic = LightpushPubsubTopic, - contentTopic = LightpushContentTopic - else: - error "failed to publish message", error = $res.error + ( + await node.wakuLightpushClient.publishWithConn( + LightpushPubsubTopic, message, conn, dPeerId + ) + ).isOkOr: + error "failed to publish message via mix", error = error.desc lp_mix_failed.inc(labelValues = ["publish_error"]) + return + + let latency = float64(getNowInNanosecondTime() - startTime) / 1_000_000.0 + lp_mix_latency.observe(latency) + lp_mix_success.inc() + notice "published message", + text = text, + timestamp = message.timestamp, + latency = latency, + psTopic = LightpushPubsubTopic, + contentTopic = LightpushContentTopic if conf.mixDisabled: await conn.close() await sleepAsync(conf.msgIntervalMilliseconds) - info "###########Sent all messages via mix" + info "Sent all messages via mix" quit(0) when isMainModule: diff --git a/examples/lightpush_mix/lightpush_publisher_mix_metrics.nim b/examples/lightpush_mix/lightpush_publisher_mix_metrics.nim index cd06b3e3e..3c467e28c 100644 --- a/examples/lightpush_mix/lightpush_publisher_mix_metrics.nim +++ b/examples/lightpush_mix/lightpush_publisher_mix_metrics.nim @@ -6,3 +6,6 @@ declarePublicCounter lp_mix_success, "number of lightpush messages sent via mix" declarePublicCounter lp_mix_failed, "number of lightpush messages failed via mix", labels = ["error"] + +declarePublicHistogram lp_mix_latency, + "lightpush publish latency via mix in milliseconds" diff --git a/simulations/mixnet/run_chat_mix.sh b/simulations/mixnet/run_chat_mix.sh index 11a28c06b..3dd6f5932 100755 --- a/simulations/mixnet/run_chat_mix.sh +++ b/simulations/mixnet/run_chat_mix.sh @@ -1 +1,2 @@ -../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE --mixnode="/ip4/127.0.0.1/tcp/60002/p2p/16Uiu2HAmLtKaFaSWDohToWhWUZFLtqzYZGPFuXwKrojFVF6az5UF:9231e86da6432502900a84f867004ce78632ab52cd8e30b1ec322cd795710c2a" --mixnode="/ip4/127.0.0.1/tcp/60003/p2p/16Uiu2HAmTEDHwAziWUSz6ZE23h5vxG2o4Nn7GazhMor4bVuMXTrA:275cd6889e1f29ca48e5b9edb800d1a94f49f13d393a0ecf1a07af753506de6c" --mixnode="/ip4/127.0.0.1/tcp/60004/p2p/16Uiu2HAmPwRKZajXtfb1Qsv45VVfRZgK3ENdfmnqzSrVm3BczF6f:e0ed594a8d506681be075e8e23723478388fb182477f7a469309a25e7076fc18" --mixnode="/ip4/127.0.0.1/tcp/60005/p2p/16Uiu2HAmRhxmCHBYdXt1RibXrjAUNJbduAhzaTHwFCZT4qWnqZAu:8fd7a1a7c19b403d231452a9b1ea40eb1cc76f455d918ef8980e7685f9eeeb1f" +../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE +#--mixnode="/ip4/127.0.0.1/tcp/60002/p2p/16Uiu2HAmLtKaFaSWDohToWhWUZFLtqzYZGPFuXwKrojFVF6az5UF:9231e86da6432502900a84f867004ce78632ab52cd8e30b1ec322cd795710c2a" --mixnode="/ip4/127.0.0.1/tcp/60003/p2p/16Uiu2HAmTEDHwAziWUSz6ZE23h5vxG2o4Nn7GazhMor4bVuMXTrA:275cd6889e1f29ca48e5b9edb800d1a94f49f13d393a0ecf1a07af753506de6c" --mixnode="/ip4/127.0.0.1/tcp/60004/p2p/16Uiu2HAmPwRKZajXtfb1Qsv45VVfRZgK3ENdfmnqzSrVm3BczF6f:e0ed594a8d506681be075e8e23723478388fb182477f7a469309a25e7076fc18" --mixnode="/ip4/127.0.0.1/tcp/60005/p2p/16Uiu2HAmRhxmCHBYdXt1RibXrjAUNJbduAhzaTHwFCZT4qWnqZAu:8fd7a1a7c19b403d231452a9b1ea40eb1cc76f455d918ef8980e7685f9eeeb1f" diff --git a/simulations/mixnet/run_chat_mix1.sh b/simulations/mixnet/run_chat_mix1.sh index 11a28c06b..7323bb3a9 100755 --- a/simulations/mixnet/run_chat_mix1.sh +++ b/simulations/mixnet/run_chat_mix1.sh @@ -1 +1,2 @@ -../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE --mixnode="/ip4/127.0.0.1/tcp/60002/p2p/16Uiu2HAmLtKaFaSWDohToWhWUZFLtqzYZGPFuXwKrojFVF6az5UF:9231e86da6432502900a84f867004ce78632ab52cd8e30b1ec322cd795710c2a" --mixnode="/ip4/127.0.0.1/tcp/60003/p2p/16Uiu2HAmTEDHwAziWUSz6ZE23h5vxG2o4Nn7GazhMor4bVuMXTrA:275cd6889e1f29ca48e5b9edb800d1a94f49f13d393a0ecf1a07af753506de6c" --mixnode="/ip4/127.0.0.1/tcp/60004/p2p/16Uiu2HAmPwRKZajXtfb1Qsv45VVfRZgK3ENdfmnqzSrVm3BczF6f:e0ed594a8d506681be075e8e23723478388fb182477f7a469309a25e7076fc18" --mixnode="/ip4/127.0.0.1/tcp/60005/p2p/16Uiu2HAmRhxmCHBYdXt1RibXrjAUNJbduAhzaTHwFCZT4qWnqZAu:8fd7a1a7c19b403d231452a9b1ea40eb1cc76f455d918ef8980e7685f9eeeb1f" +../../build/chat2mix --cluster-id=2 --num-shards-in-network=1 --shard=0 --servicenode="/ip4/127.0.0.1/tcp/60001/p2p/16Uiu2HAmPiEs2ozjjJF2iN2Pe2FYeMC9w4caRHKYdLdAfjgbWM6o" --log-level=TRACE +#--mixnode="/ip4/127.0.0.1/tcp/60002/p2p/16Uiu2HAmLtKaFaSWDohToWhWUZFLtqzYZGPFuXwKrojFVF6az5UF:9231e86da6432502900a84f867004ce78632ab52cd8e30b1ec322cd795710c2a" --mixnode="/ip4/127.0.0.1/tcp/60003/p2p/16Uiu2HAmTEDHwAziWUSz6ZE23h5vxG2o4Nn7GazhMor4bVuMXTrA:275cd6889e1f29ca48e5b9edb800d1a94f49f13d393a0ecf1a07af753506de6c" --mixnode="/ip4/127.0.0.1/tcp/60004/p2p/16Uiu2HAmPwRKZajXtfb1Qsv45VVfRZgK3ENdfmnqzSrVm3BczF6f:e0ed594a8d506681be075e8e23723478388fb182477f7a469309a25e7076fc18" --mixnode="/ip4/127.0.0.1/tcp/60005/p2p/16Uiu2HAmRhxmCHBYdXt1RibXrjAUNJbduAhzaTHwFCZT4qWnqZAu:8fd7a1a7c19b403d231452a9b1ea40eb1cc76f455d918ef8980e7685f9eeeb1f" diff --git a/tests/test_waku_rendezvous.nim b/tests/test_waku_rendezvous.nim index fa2efbd47..d3dd6f920 100644 --- a/tests/test_waku_rendezvous.nim +++ b/tests/test_waku_rendezvous.nim @@ -1,12 +1,20 @@ {.used.} -import std/options, chronos, testutils/unittests, libp2p/builders +import + std/options, + chronos, + testutils/unittests, + libp2p/builders, + libp2p/protocols/rendezvous import waku/waku_core/peers, + waku/waku_core/codecs, waku/node/waku_node, waku/node/peer_manager/peer_manager, waku/waku_rendezvous/protocol, + waku/waku_rendezvous/common, + waku/waku_rendezvous/waku_peer_record, ./testlib/[wakucore, wakunode] procSuite "Waku Rendezvous": @@ -50,18 +58,26 @@ procSuite "Waku Rendezvous": node2.peerManager.addPeer(peerInfo3) node3.peerManager.addPeer(peerInfo2) - let namespace = "test/name/space" - - let res = await node1.wakuRendezvous.batchAdvertise( - namespace, 60.seconds, @[peerInfo2.peerId] - ) + let res = await node1.wakuRendezvous.advertiseAll() assert res.isOk(), $res.error + # Rendezvous Request API requires dialing first + let connOpt = + await node3.peerManager.dialPeer(peerInfo2.peerId, WakuRendezVousCodec) + require: + connOpt.isSome - let response = - await node3.wakuRendezvous.batchRequest(namespace, 1, @[peerInfo2.peerId]) - assert response.isOk(), $response.error - let records = response.get() + var records: seq[WakuPeerRecord] + try: + records = await rendezvous.request[WakuPeerRecord]( + node3.wakuRendezvous, + Opt.some(computeMixNamespace(clusterId)), + Opt.some(1), + Opt.some(@[peerInfo2.peerId]), + ) + except CatchableError as e: + assert false, "Request failed with exception: " & e.msg check: records.len == 1 records[0].peerId == peerInfo1.peerId + #records[0].mixPubKey == $node1.wakuMix.pubKey diff --git a/tests/waku_discv5/test_waku_discv5.nim b/tests/waku_discv5/test_waku_discv5.nim index 6685bda32..20a0c6965 100644 --- a/tests/waku_discv5/test_waku_discv5.nim +++ b/tests/waku_discv5/test_waku_discv5.nim @@ -426,7 +426,6 @@ suite "Waku Discovery v5": confBuilder.withNodeKey(libp2p_keys.PrivateKey.random(Secp256k1, myRng[])[]) confBuilder.discv5Conf.withEnabled(true) confBuilder.discv5Conf.withUdpPort(9000.Port) - let conf = confBuilder.build().valueOr: raiseAssert error @@ -468,6 +467,9 @@ suite "Waku Discovery v5": # leave some time for discv5 to act await sleepAsync(chronos.seconds(10)) + # Connect peers via peer manager to ensure identify happens + discard await waku0.node.peerManager.connectPeer(waku1.node.switch.peerInfo) + var r = waku0.node.peerManager.selectPeer(WakuPeerExchangeCodec) assert r.isSome(), "could not retrieve peer mounting WakuPeerExchangeCodec" @@ -480,7 +482,7 @@ suite "Waku Discovery v5": r = waku2.node.peerManager.selectPeer(WakuPeerExchangeCodec) assert r.isSome(), "could not retrieve peer mounting WakuPeerExchangeCodec" - r = waku2.node.peerManager.selectPeer(RendezVousCodec) + r = waku2.node.peerManager.selectPeer(WakuRendezVousCodec) assert r.isSome(), "could not retrieve peer mounting RendezVousCodec" asyncTest "Discv5 bootstrap nodes should be added to the peer store": diff --git a/vendor/nim-libp2p b/vendor/nim-libp2p index 0309685cd..e82080f7b 160000 --- a/vendor/nim-libp2p +++ b/vendor/nim-libp2p @@ -1 +1 @@ -Subproject commit 0309685cd27d4bf763c8b3be86a76c33bcfe67ea +Subproject commit e82080f7b1aa61c6d35fa5311b873f41eff4bb52 diff --git a/waku.nimble b/waku.nimble index c63d20246..79fdd9fd6 100644 --- a/waku.nimble +++ b/waku.nimble @@ -24,7 +24,7 @@ requires "nim >= 2.2.4", "stew", "stint", "metrics", - "libp2p >= 1.14.2", + "libp2p >= 1.14.3", "web3", "presto", "regex", diff --git a/waku/common/callbacks.nim b/waku/common/callbacks.nim index 9b8590152..83209ef24 100644 --- a/waku/common/callbacks.nim +++ b/waku/common/callbacks.nim @@ -1,5 +1,7 @@ -import ../waku_enr/capabilities +import waku/waku_enr/capabilities, waku/waku_rendezvous/waku_peer_record type GetShards* = proc(): seq[uint16] {.closure, gcsafe, raises: [].} type GetCapabilities* = proc(): seq[Capabilities] {.closure, gcsafe, raises: [].} + +type GetWakuPeerRecord* = proc(): WakuPeerRecord {.closure, gcsafe, raises: [].} diff --git a/waku/factory/node_factory.nim b/waku/factory/node_factory.nim index 488d07c06..34fc958fe 100644 --- a/waku/factory/node_factory.nim +++ b/waku/factory/node_factory.nim @@ -163,6 +163,15 @@ proc setupProtocols( error "Unrecoverable error occurred", error = msg quit(QuitFailure) + #mount mix + if conf.mixConf.isSome(): + ( + await node.mountMix( + conf.clusterId, conf.mixConf.get().mixKey, conf.mixConf.get().mixnodes + ) + ).isOkOr: + return err("failed to mount waku mix protocol: " & $error) + if conf.storeServiceConf.isSome(): let storeServiceConf = conf.storeServiceConf.get() if storeServiceConf.supportV2: @@ -327,9 +336,9 @@ proc setupProtocols( protectedShard = shardKey.shard, publicKey = shardKey.key node.wakuRelay.addSignedShardsValidator(subscribedProtectedShards, conf.clusterId) - # Only relay nodes should be rendezvous points. - if conf.rendezvous: - await node.mountRendezvous(conf.clusterId) + if conf.rendezvous: + await node.mountRendezvous(conf.clusterId) + await node.mountRendezvousClient(conf.clusterId) # Keepalive mounted on all nodes try: @@ -414,14 +423,6 @@ proc setupProtocols( if conf.peerExchangeDiscovery: await node.mountPeerExchangeClient() - #mount mix - if conf.mixConf.isSome(): - ( - await node.mountMix( - conf.clusterId, conf.mixConf.get().mixKey, conf.mixConf.get().mixnodes - ) - ).isOkOr: - return err("failed to mount waku mix protocol: " & $error) return ok() ## Start node diff --git a/waku/factory/waku_conf.nim b/waku/factory/waku_conf.nim index 89ffb366c..899008221 100644 --- a/waku/factory/waku_conf.nim +++ b/waku/factory/waku_conf.nim @@ -154,7 +154,8 @@ proc logConf*(conf: WakuConf) = store = conf.storeServiceConf.isSome(), filter = conf.filterServiceConf.isSome(), lightPush = conf.lightPush, - peerExchange = conf.peerExchangeService + peerExchange = conf.peerExchangeService, + rendezvous = conf.rendezvous info "Configuration. Network", cluster = conf.clusterId diff --git a/waku/node/peer_manager/waku_peer_store.nim b/waku/node/peer_manager/waku_peer_store.nim index c9e2d4817..9cde53fe1 100644 --- a/waku/node/peer_manager/waku_peer_store.nim +++ b/waku/node/peer_manager/waku_peer_store.nim @@ -6,7 +6,8 @@ import chronicles, eth/p2p/discoveryv5/enr, libp2p/builders, - libp2p/peerstore + libp2p/peerstore, + libp2p/crypto/curve25519 import ../../waku_core, @@ -42,6 +43,9 @@ type # Keeps track of peer shards ShardBook* = ref object of PeerBook[seq[uint16]] + # Keeps track of Mix protocol public keys of peers + MixPubKeyBook* = ref object of PeerBook[Curve25519Key] + proc getPeer*(peerStore: PeerStore, peerId: PeerId): RemotePeerInfo = let addresses = if peerStore[LastSeenBook][peerId].isSome(): @@ -68,6 +72,11 @@ proc getPeer*(peerStore: PeerStore, peerId: PeerId): RemotePeerInfo = direction: peerStore[DirectionBook][peerId], lastFailedConn: peerStore[LastFailedConnBook][peerId], numberFailedConn: peerStore[NumberFailedConnBook][peerId], + mixPubKey: + if peerStore[MixPubKeyBook][peerId] != default(Curve25519Key): + some(peerStore[MixPubKeyBook][peerId]) + else: + none(Curve25519Key), ) proc delete*(peerStore: PeerStore, peerId: PeerId) = @@ -87,6 +96,13 @@ proc peers*(peerStore: PeerStore): seq[RemotePeerInfo] = return allKeys.mapIt(peerStore.getPeer(it)) proc addPeer*(peerStore: PeerStore, peer: RemotePeerInfo, origin = UnknownOrigin) = + ## Storing MixPubKey even if peer is already present as this info might be new + ## or updated. + if peer.mixPubKey.isSome(): + trace "adding mix pub key to peer store", + peer_id = $peer.peerId, mix_pub_key = $peer.mixPubKey.get() + peerStore[MixPubKeyBook].book[peer.peerId] = peer.mixPubKey.get() + ## Notice that the origin parameter is used to manually override the given peer origin. ## At the time of writing, this is used in waku_discv5 or waku_node (peer exchange.) if peerStore[AddressBook][peer.peerId] == peer.addrs and @@ -113,6 +129,7 @@ proc addPeer*(peerStore: PeerStore, peer: RemotePeerInfo, origin = UnknownOrigin peerStore[ProtoBook][peer.peerId] = protos ## We don't care whether the item was already present in the table or not. Hence, we always discard the hasKeyOrPut's bool returned value + discard peerStore[AgentBook].book.hasKeyOrPut(peer.peerId, peer.agent) discard peerStore[ProtoVersionBook].book.hasKeyOrPut(peer.peerId, peer.protoVersion) discard peerStore[KeyBook].book.hasKeyOrPut(peer.peerId, peer.publicKey) diff --git a/waku/node/waku_node.nim b/waku/node/waku_node.nim index 114775951..65b2093bb 100644 --- a/waku/node/waku_node.nim +++ b/waku/node/waku_node.nim @@ -22,6 +22,7 @@ import libp2p/transports/tcptransport, libp2p/transports/wstransport, libp2p/utility, + libp2p/utils/offsettedseq, libp2p/protocols/mix, libp2p/protocols/mix/mix_protocol @@ -43,6 +44,8 @@ import ../waku_filter_v2/client as filter_client, ../waku_metadata, ../waku_rendezvous/protocol, + ../waku_rendezvous/client as rendezvous_client, + ../waku_rendezvous/waku_peer_record, ../waku_lightpush_legacy/client as legacy_ligntpuhs_client, ../waku_lightpush_legacy as legacy_lightpush_protocol, ../waku_lightpush/client as ligntpuhs_client, @@ -121,6 +124,7 @@ type libp2pPing*: Ping rng*: ref rand.HmacDrbgContext wakuRendezvous*: WakuRendezVous + wakuRendezvousClient*: rendezvous_client.WakuRendezVousClient announcedAddresses*: seq[MultiAddress] started*: bool # Indicates that node has started listening topicSubscriptionQueue*: AsyncEventQueue[SubscriptionEvent] @@ -148,6 +152,17 @@ proc getCapabilitiesGetter(node: WakuNode): GetCapabilities = return @[] return node.enr.getCapabilities() +proc getWakuPeerRecordGetter(node: WakuNode): GetWakuPeerRecord = + return proc(): WakuPeerRecord {.closure, gcsafe, raises: [].} = + var mixKey: string + if not node.wakuMix.isNil(): + mixKey = node.wakuMix.pubKey.to0xHex() + return WakuPeerRecord.init( + peerId = node.switch.peerInfo.peerId, + addresses = node.announcedAddresses, + mixKey = mixKey, + ) + proc new*( T: type WakuNode, netConfig: NetConfig, @@ -257,12 +272,12 @@ proc mountMix*( return err("Failed to convert multiaddress to string.") info "local addr", localaddr = localaddrStr - let nodeAddr = localaddrStr & "/p2p/" & $node.peerId node.wakuMix = WakuMix.new( - nodeAddr, node.peerManager, clusterId, mixPrivKey, mixnodes + localaddrStr, node.peerManager, clusterId, mixPrivKey, mixnodes ).valueOr: error "Waku Mix protocol initialization failed", err = error return + #TODO: should we do the below only for exit node? Also, what if multiple protocols use mix? node.wakuMix.registerDestReadBehavior(WakuLightPushCodec, readLp(int(-1))) let catchRes = catch: node.switch.mount(node.wakuMix) @@ -346,6 +361,18 @@ proc selectRandomPeers*(peers: seq[PeerId], numRandomPeers: int): seq[PeerId] = shuffle(randomPeers) return randomPeers[0 ..< min(len(randomPeers), numRandomPeers)] +proc mountRendezvousClient*(node: WakuNode, clusterId: uint16) {.async: (raises: []).} = + info "mounting rendezvous client" + + node.wakuRendezvousClient = rendezvous_client.WakuRendezVousClient.new( + node.switch, node.peerManager, clusterId + ).valueOr: + error "initializing waku rendezvous client failed", error = error + return + + if node.started: + await node.wakuRendezvousClient.start() + proc mountRendezvous*(node: WakuNode, clusterId: uint16) {.async: (raises: []).} = info "mounting rendezvous discovery protocol" @@ -355,6 +382,7 @@ proc mountRendezvous*(node: WakuNode, clusterId: uint16) {.async: (raises: []).} clusterId, node.getShardsGetter(), node.getCapabilitiesGetter(), + node.getWakuPeerRecordGetter(), ).valueOr: error "initializing waku rendezvous failed", error = error return @@ -362,6 +390,11 @@ proc mountRendezvous*(node: WakuNode, clusterId: uint16) {.async: (raises: []).} if node.started: await node.wakuRendezvous.start() + try: + node.switch.mount(node.wakuRendezvous, protocolMatcher(WakuRendezVousCodec)) + except LPError: + error "failed to mount wakuRendezvous", error = getCurrentExceptionMsg() + proc isBindIpWithZeroPort(inputMultiAdd: MultiAddress): bool = let inputStr = $inputMultiAdd if inputStr.contains("0.0.0.0/tcp/0") or inputStr.contains("127.0.0.1/tcp/0"): @@ -438,6 +471,9 @@ proc start*(node: WakuNode) {.async.} = if not node.wakuRendezvous.isNil(): await node.wakuRendezvous.start() + if not node.wakuRendezvousClient.isNil(): + await node.wakuRendezvousClient.start() + if not node.wakuStoreReconciliation.isNil(): node.wakuStoreReconciliation.start() @@ -499,6 +535,9 @@ proc stop*(node: WakuNode) {.async.} = if not node.wakuRendezvous.isNil(): await node.wakuRendezvous.stopWait() + if not node.wakuRendezvousClient.isNil(): + await node.wakuRendezvousClient.stopWait() + node.started = false proc isReady*(node: WakuNode): Future[bool] {.async: (raises: [Exception]).} = diff --git a/waku/waku_core/codecs.nim b/waku/waku_core/codecs.nim index 6dcdfe2f5..0d9394c71 100644 --- a/waku/waku_core/codecs.nim +++ b/waku/waku_core/codecs.nim @@ -10,3 +10,4 @@ const WakuMetadataCodec* = "/vac/waku/metadata/1.0.0" WakuPeerExchangeCodec* = "/vac/waku/peer-exchange/2.0.0-alpha1" WakuLegacyStoreCodec* = "/vac/waku/store/2.0.0-beta4" + WakuRendezVousCodec* = "/vac/waku/rendezvous/1.0.0" diff --git a/waku/waku_core/peers.nim b/waku/waku_core/peers.nim index 76ff29aa0..48c994403 100644 --- a/waku/waku_core/peers.nim +++ b/waku/waku_core/peers.nim @@ -9,6 +9,7 @@ import eth/p2p/discoveryv5/enr, eth/net/utils, libp2p/crypto/crypto, + libp2p/crypto/curve25519, libp2p/crypto/secp, libp2p/errors, libp2p/multiaddress, @@ -49,6 +50,7 @@ type RemotePeerInfo* = ref object enr*: Option[enr.Record] protocols*: seq[string] shards*: seq[uint16] + mixPubKey*: Option[Curve25519Key] agent*: string protoVersion*: string @@ -84,6 +86,7 @@ proc init*( direction: PeerDirection = UnknownDirection, lastFailedConn: Moment = Moment.init(0, Second), numberFailedConn: int = 0, + mixPubKey: Option[Curve25519Key] = none(Curve25519Key), ): T = RemotePeerInfo( peerId: peerId, @@ -100,6 +103,7 @@ proc init*( direction: direction, lastFailedConn: lastFailedConn, numberFailedConn: numberFailedConn, + mixPubKey: mixPubKey, ) proc init*( diff --git a/waku/waku_lightpush/client.nim b/waku/waku_lightpush/client.nim index 4d0c49a84..d68552304 100644 --- a/waku/waku_lightpush/client.nim +++ b/waku/waku_lightpush/client.nim @@ -45,8 +45,13 @@ proc sendPushRequest( defer: await connection.closeWithEOF() - - await connection.writeLP(req.encode().buffer) + try: + await connection.writeLP(req.encode().buffer) + except CatchableError: + error "failed to send push request", error = getCurrentExceptionMsg() + return lightpushResultInternalError( + "failed to send push request: " & getCurrentExceptionMsg() + ) var buffer: seq[byte] try: @@ -56,9 +61,8 @@ proc sendPushRequest( return lightpushResultInternalError( "Failed to read response from peer: " & getCurrentExceptionMsg() ) - let response = LightpushResponse.decode(buffer).valueOr: - error "failed to decode response" + error "failed to decode response", error = $error waku_lightpush_v3_errors.inc(labelValues = [decodeRpcFailure]) return lightpushResultInternalError(decodeRpcFailure) diff --git a/waku/waku_mix/protocol.nim b/waku/waku_mix/protocol.nim index 34b50f8a9..d3d765df8 100644 --- a/waku/waku_mix/protocol.nim +++ b/waku/waku_mix/protocol.nim @@ -6,6 +6,8 @@ import libp2p/crypto/curve25519, libp2p/protocols/mix, libp2p/protocols/mix/mix_node, + libp2p/protocols/mix/mix_protocol, + libp2p/protocols/mix/mix_metrics, libp2p/[multiaddress, multicodec, peerid], eth/common/keys @@ -34,22 +36,18 @@ type multiAddr*: string pubKey*: Curve25519Key -proc mixPoolFilter*(cluster: Option[uint16], peer: RemotePeerInfo): bool = +proc filterMixNodes(cluster: Option[uint16], peer: RemotePeerInfo): bool = # Note that origin based(discv5) filtering is not done intentionally # so that more mix nodes can be discovered. - if peer.enr.isNone(): - trace "peer has no ENR", peer = $peer + if peer.mixPubKey.isNone(): + trace "remote peer has no mix Pub Key", peer = $peer return false - if cluster.isSome() and peer.enr.get().isClusterMismatched(cluster.get()): + if cluster.isSome() and peer.enr.isSome() and + peer.enr.get().isClusterMismatched(cluster.get()): trace "peer has mismatching cluster", peer = $peer return false - # Filter if mix is enabled - if not peer.enr.get().supportsCapability(Capabilities.Mix): - trace "peer doesn't support mix", peer = $peer - return false - return true proc appendPeerIdToMultiaddr*(multiaddr: MultiAddress, peerId: PeerId): MultiAddress = @@ -74,34 +72,52 @@ func getIPv4Multiaddr*(maddrs: seq[MultiAddress]): Option[MultiAddress] = trace "no ipv4 multiaddr found" return none(MultiAddress) -#[ Not deleting as these can be reused once discovery is sorted - proc populateMixNodePool*(mix: WakuMix) = +proc populateMixNodePool*(mix: WakuMix) = # populate only peers that i) are reachable ii) share cluster iii) support mix let remotePeers = mix.peerManager.switch.peerStore.peers().filterIt( - mixPoolFilter(some(mix.clusterId), it) + filterMixNodes(some(mix.clusterId), it) ) var mixNodes = initTable[PeerId, MixPubInfo]() for i in 0 ..< min(remotePeers.len, 100): - let remotePeerENR = remotePeers[i].enr.get() let ipv4addr = getIPv4Multiaddr(remotePeers[i].addrs).valueOr: trace "peer has no ipv4 address", peer = $remotePeers[i] continue - let maddrWithPeerId = - toString(appendPeerIdToMultiaddr(ipv4addr, remotePeers[i].peerId)) - trace "remote peer ENR", - peerId = remotePeers[i].peerId, enr = remotePeerENR, maddr = maddrWithPeerId + let maddrWithPeerId = appendPeerIdToMultiaddr(ipv4addr, remotePeers[i].peerId) + trace "remote peer info", info = remotePeers[i] - let peerMixPubKey = mixKey(remotePeerENR).get() - let mixNodePubInfo = - createMixPubInfo(maddrWithPeerId.value, intoCurve25519Key(peerMixPubKey)) + if remotePeers[i].mixPubKey.isNone(): + trace "peer has no mix Pub Key", remotePeerId = $remotePeers[i] + continue + + let peerMixPubKey = remotePeers[i].mixPubKey.get() + var peerPubKey: crypto.PublicKey + if not remotePeers[i].peerId.extractPublicKey(peerPubKey): + warn "Failed to extract public key from peerId, skipping node", + remotePeerId = remotePeers[i].peerId + continue + + if peerPubKey.scheme != PKScheme.Secp256k1: + warn "Peer public key is not Secp256k1, skipping node", + remotePeerId = remotePeers[i].peerId, scheme = peerPubKey.scheme + continue + + let mixNodePubInfo = MixPubInfo.init( + remotePeers[i].peerId, + ipv4addr, + intoCurve25519Key(peerMixPubKey), + peerPubKey.skkey, + ) + trace "adding mix node to pool", + remotePeerId = remotePeers[i].peerId, multiAddr = $ipv4addr mixNodes[remotePeers[i].peerId] = mixNodePubInfo - mix_pool_size.set(len(mixNodes)) # set the mix node pool mix.setNodePool(mixNodes) + mix_pool_size.set(len(mixNodes)) trace "mix node pool updated", poolSize = mix.getNodePoolSize() +# Once mix protocol starts to use info from PeerStore, then this can be removed. proc startMixNodePoolMgr*(mix: WakuMix) {.async.} = info "starting mix node pool manager" # try more aggressively to populate the pool at startup @@ -115,9 +131,10 @@ proc startMixNodePoolMgr*(mix: WakuMix) {.async.} = # TODO: make interval configurable heartbeat "Updating mix node pool", 5.seconds: mix.populateMixNodePool() - ]# -proc toMixNodeTable(bootnodes: seq[MixNodePubInfo]): Table[PeerId, MixPubInfo] = +proc processBootNodes( + bootnodes: seq[MixNodePubInfo], peermgr: PeerManager +): Table[PeerId, MixPubInfo] = var mixNodes = initTable[PeerId, MixPubInfo]() for node in bootnodes: let pInfo = parsePeerInfo(node.multiAddr).valueOr: @@ -140,6 +157,11 @@ proc toMixNodeTable(bootnodes: seq[MixNodePubInfo]): Table[PeerId, MixPubInfo] = continue mixNodes[peerId] = MixPubInfo.init(peerId, multiAddr, node.pubKey, peerPubKey.skkey) + + peermgr.addPeer( + RemotePeerInfo.init(peerId, @[multiAddr], mixPubKey = some(node.pubKey)) + ) + mix_pool_size.set(len(mixNodes)) info "using mix bootstrap nodes ", bootNodes = mixNodes return mixNodes @@ -152,7 +174,7 @@ proc new*( bootnodes: seq[MixNodePubInfo], ): WakuMixResult[T] = let mixPubKey = public(mixPrivKey) - info "mixPrivKey", mixPrivKey = mixPrivKey, mixPubKey = mixPubKey + info "mixPubKey", mixPubKey = mixPubKey let nodeMultiAddr = MultiAddress.init(nodeAddr).valueOr: return err("failed to parse mix node address: " & $nodeAddr & ", error: " & error) let localMixNodeInfo = initMixNodeInfo( @@ -160,17 +182,18 @@ proc new*( peermgr.switch.peerInfo.publicKey.skkey, peermgr.switch.peerInfo.privateKey.skkey, ) if bootnodes.len < mixMixPoolSize: - warn "publishing with mix won't work as there are less than 3 mix nodes in node pool" - let initTable = toMixNodeTable(bootnodes) + warn "publishing with mix won't work until there are 3 mix nodes in node pool" + let initTable = processBootNodes(bootnodes, peermgr) + if len(initTable) < mixMixPoolSize: - warn "publishing with mix won't work as there are less than 3 mix nodes in node pool" + warn "publishing with mix won't work until there are 3 mix nodes in node pool" var m = WakuMix(peerManager: peermgr, clusterId: clusterId, pubKey: mixPubKey) procCall MixProtocol(m).init(localMixNodeInfo, initTable, peermgr.switch) return ok(m) method start*(mix: WakuMix) = info "starting waku mix protocol" - #mix.nodePoolLoopHandle = mix.startMixNodePoolMgr() This can be re-enabled once discovery is addressed + mix.nodePoolLoopHandle = mix.startMixNodePoolMgr() method stop*(mix: WakuMix) {.async.} = if mix.nodePoolLoopHandle.isNil(): diff --git a/waku/waku_rendezvous/client.nim b/waku/waku_rendezvous/client.nim new file mode 100644 index 000000000..09e789774 --- /dev/null +++ b/waku/waku_rendezvous/client.nim @@ -0,0 +1,142 @@ +{.push raises: [].} + +import + std/[options, sequtils, tables], + results, + chronos, + chronicles, + libp2p/protocols/rendezvous, + libp2p/crypto/curve25519, + libp2p/switch, + libp2p/utils/semaphore + +import metrics except collect + +import + waku/node/peer_manager, + waku/waku_core/peers, + waku/waku_core/codecs, + ./common, + ./waku_peer_record + +logScope: + topics = "waku rendezvous client" + +declarePublicCounter rendezvousPeerFoundTotal, + "total number of peers found via rendezvous" + +type WakuRendezVousClient* = ref object + switch: Switch + peerManager: PeerManager + clusterId: uint16 + requestInterval: timer.Duration + periodicRequestFut: Future[void] + # Internal rendezvous instance for making requests + rdv: GenericRendezVous[WakuPeerRecord] + +const MaxSimultanesousAdvertisements = 5 +const RendezVousLookupInterval = 10.seconds + +proc requestAll*( + self: WakuRendezVousClient +): Future[Result[void, string]] {.async: (raises: []).} = + trace "waku rendezvous client requests started" + + let namespace = computeMixNamespace(self.clusterId) + + # Get a random WakuRDV peer + let rpi = self.peerManager.selectPeer(WakuRendezVousCodec).valueOr: + return err("could not get a peer supporting WakuRendezVousCodec") + + var records: seq[WakuPeerRecord] + try: + # Use the libp2p rendezvous request method + records = await self.rdv.request( + Opt.some(namespace), Opt.some(PeersRequestedCount), Opt.some(@[rpi.peerId]) + ) + except CatchableError as e: + return err("rendezvous request failed: " & e.msg) + + trace "waku rendezvous client request got peers", count = records.len + for record in records: + if not self.switch.peerStore.peerExists(record.peerId): + rendezvousPeerFoundTotal.inc() + if record.mixKey.len == 0 or record.peerId == self.switch.peerInfo.peerId: + continue + trace "adding peer from rendezvous", + peerId = record.peerId, addresses = $record.addresses, mixKey = record.mixKey + let rInfo = RemotePeerInfo.init( + record.peerId, + record.addresses, + mixPubKey = some(intoCurve25519Key(fromHex(record.mixKey))), + ) + self.peerManager.addPeer(rInfo) + + trace "waku rendezvous client request finished" + + return ok() + +proc periodicRequests(self: WakuRendezVousClient) {.async.} = + info "waku rendezvous periodic requests started", interval = self.requestInterval + + # infinite loop + while true: + await sleepAsync(self.requestInterval) + + (await self.requestAll()).isOkOr: + error "waku rendezvous requests failed", error = error + + # Exponential backoff + +#[ TODO: Reevaluate for mix, maybe be aggresive in the start until a sizeable pool is built and then backoff + self.requestInterval += self.requestInterval + + if self.requestInterval >= 1.days: + break ]# + +proc new*( + T: type WakuRendezVousClient, + switch: Switch, + peerManager: PeerManager, + clusterId: uint16, +): Result[T, string] {.raises: [].} = + # Create a minimal GenericRendezVous instance for client-side requests + # We don't need the full server functionality, just the request method + let rng = newRng() + let rdv = GenericRendezVous[WakuPeerRecord]( + switch: switch, + rng: rng, + sema: newAsyncSemaphore(MaxSimultanesousAdvertisements), + minDuration: rendezvous.MinimumAcceptedDuration, + maxDuration: rendezvous.MaximumDuration, + minTTL: rendezvous.MinimumAcceptedDuration.seconds.uint64, + maxTTL: rendezvous.MaximumDuration.seconds.uint64, + peers: @[], # Will be populated from selectPeer calls + cookiesSaved: initTable[PeerId, Table[string, seq[byte]]](), + peerRecordValidator: checkWakuPeerRecord, + ) + + # Set codec separately as it's inherited from LPProtocol + rdv.codec = WakuRendezVousCodec + + let client = T( + switch: switch, + peerManager: peerManager, + clusterId: clusterId, + requestInterval: RendezVousLookupInterval, + rdv: rdv, + ) + + info "waku rendezvous client initialized", clusterId = clusterId + + return ok(client) + +proc start*(self: WakuRendezVousClient) {.async: (raises: []).} = + self.periodicRequestFut = self.periodicRequests() + info "waku rendezvous client started" + +proc stopWait*(self: WakuRendezVousClient) {.async: (raises: []).} = + if not self.periodicRequestFut.isNil(): + await self.periodicRequestFut.cancelAndWait() + + info "waku rendezvous client stopped" diff --git a/waku/waku_rendezvous/common.nim b/waku/waku_rendezvous/common.nim index 6125ac860..18c633efb 100644 --- a/waku/waku_rendezvous/common.nim +++ b/waku/waku_rendezvous/common.nim @@ -11,6 +11,14 @@ const DefaultRequestsInterval* = 1.minutes const MaxRegistrationInterval* = 5.minutes const PeersRequestedCount* = 12 +proc computeMixNamespace*(clusterId: uint16): string = + var namespace = "rs/" + + namespace &= $clusterId + namespace &= "/mix" + + return namespace + proc computeNamespace*(clusterId: uint16, shard: uint16): string = var namespace = "rs/" diff --git a/waku/waku_rendezvous/protocol.nim b/waku/waku_rendezvous/protocol.nim index 0eb55d350..ed414fa42 100644 --- a/waku/waku_rendezvous/protocol.nim +++ b/waku/waku_rendezvous/protocol.nim @@ -1,70 +1,91 @@ {.push raises: [].} import - std/[sugar, options], + std/[sugar, options, sequtils, tables], results, chronos, chronicles, - metrics, + stew/byteutils, libp2p/protocols/rendezvous, + libp2p/protocols/rendezvous/protobuf, + libp2p/discovery/discoverymngr, + libp2p/utils/semaphore, + libp2p/utils/offsettedseq, + libp2p/crypto/curve25519, libp2p/switch, libp2p/utility +import metrics except collect + import ../node/peer_manager, ../common/callbacks, ../waku_enr/capabilities, ../waku_core/peers, - ../waku_core/topics, - ../waku_core/topics/pubsub_topic, - ./common + ../waku_core/codecs, + ./common, + ./waku_peer_record logScope: topics = "waku rendezvous" -declarePublicCounter rendezvousPeerFoundTotal, - "total number of peers found via rendezvous" - -type WakuRendezVous* = ref object - rendezvous: Rendezvous +type WakuRendezVous* = ref object of GenericRendezVous[WakuPeerRecord] peerManager: PeerManager clusterId: uint16 getShards: GetShards getCapabilities: GetCapabilities + getPeerRecord: GetWakuPeerRecord registrationInterval: timer.Duration periodicRegistrationFut: Future[void] - requestInterval: timer.Duration - periodicRequestFut: Future[void] +const MaximumNamespaceLen = 255 -proc batchAdvertise*( +method discover*( + self: WakuRendezVous, conn: Connection, d: Discover +) {.async: (raises: [CancelledError, LPStreamError]).} = + # Override discover method to avoid collect macro generic instantiation issues + trace "Received Discover", peerId = conn.peerId, ns = d.ns + await procCall GenericRendezVous[WakuPeerRecord](self).discover(conn, d) + +proc advertise*( self: WakuRendezVous, namespace: string, - ttl: Duration = DefaultRegistrationTTL, peers: seq[PeerId], + ttl: timer.Duration = self.minDuration, ): Future[Result[void, string]] {.async: (raises: []).} = - ## Register with all rendezvous peers under a namespace + trace "advertising via waku rendezvous", + namespace = namespace, ttl = ttl, peers = $peers, peerRecord = $self.getPeerRecord() + let se = SignedPayload[WakuPeerRecord].init( + self.switch.peerInfo.privateKey, self.getPeerRecord() + ).valueOr: + return + err("rendezvous advertisement failed: Failed to sign Waku Peer Record: " & $error) + let sprBuff = se.encode().valueOr: + return err("rendezvous advertisement failed: Wrong Signed Peer Record: " & $error) # rendezvous.advertise expects already opened connections # must dial first + var futs = collect(newSeq): for peerId in peers: - self.peerManager.dialPeer(peerId, RendezVousCodec) + self.peerManager.dialPeer(peerId, self.codec) let dialCatch = catch: await allFinished(futs) - futs = dialCatch.valueOr: - return err("batchAdvertise: " & error.msg) + if dialCatch.isErr(): + return err("advertise: " & dialCatch.error.msg) + + futs = dialCatch.get() let conns = collect(newSeq): for fut in futs: let catchable = catch: fut.read() - catchable.isOkOr: - warn "a rendezvous dial failed", cause = error.msg + if catchable.isErr(): + warn "a rendezvous dial failed", cause = catchable.error.msg continue let connOpt = catchable.get() @@ -74,149 +95,34 @@ proc batchAdvertise*( conn - let advertCatch = catch: - await self.rendezvous.advertise(namespace, Opt.some(ttl)) - - for conn in conns: - await conn.close() - - advertCatch.isOkOr: - return err("batchAdvertise: " & error.msg) + if conns.len == 0: + return err("could not establish any connections to rendezvous peers") + try: + await self.advertise(namespace, ttl, peers, sprBuff) + except Exception as e: + return err("rendezvous advertisement failed: " & e.msg) + finally: + for conn in conns: + await conn.close() return ok() -proc batchRequest*( - self: WakuRendezVous, - namespace: string, - count: int = DiscoverLimit, - peers: seq[PeerId], -): Future[Result[seq[PeerRecord], string]] {.async: (raises: []).} = - ## Request all records from all rendezvous peers matching a namespace - - # rendezvous.request expects already opened connections - # must dial first - var futs = collect(newSeq): - for peerId in peers: - self.peerManager.dialPeer(peerId, RendezVousCodec) - - let dialCatch = catch: - await allFinished(futs) - - futs = dialCatch.valueOr: - return err("batchRequest: " & error.msg) - - let conns = collect(newSeq): - for fut in futs: - let catchable = catch: - fut.read() - - catchable.isOkOr: - warn "a rendezvous dial failed", cause = error.msg - continue - - let connOpt = catchable.get() - - let conn = connOpt.valueOr: - continue - - conn - - let reqCatch = catch: - await self.rendezvous.request(Opt.some(namespace), Opt.some(count), Opt.some(peers)) - - for conn in conns: - await conn.close() - - reqCatch.isOkOr: - return err("batchRequest: " & error.msg) - - return ok(reqCatch.get()) - -proc advertiseAll( +proc advertiseAll*( self: WakuRendezVous ): Future[Result[void, string]] {.async: (raises: []).} = - info "waku rendezvous advertisements started" + trace "waku rendezvous advertisements started" - let shards = self.getShards() - - let futs = collect(newSeq): - for shardId in shards: - # Get a random RDV peer for that shard - - let pubsub = - toPubsubTopic(RelayShard(clusterId: self.clusterId, shardId: shardId)) - - let rpi = self.peerManager.selectPeer(RendezVousCodec, some(pubsub)).valueOr: - continue - - let namespace = computeNamespace(self.clusterId, shardId) - - # Advertise yourself on that peer - self.batchAdvertise(namespace, DefaultRegistrationTTL, @[rpi.peerId]) - - if futs.len < 1: + let rpi = self.peerManager.selectPeer(self.codec).valueOr: return err("could not get a peer supporting RendezVousCodec") - let catchable = catch: - await allFinished(futs) + let namespace = computeMixNamespace(self.clusterId) - catchable.isOkOr: - return err(error.msg) + # Advertise yourself on that peer + let res = await self.advertise(namespace, @[rpi.peerId]) - for fut in catchable.get(): - if fut.failed(): - warn "a rendezvous advertisement failed", cause = fut.error.msg + trace "waku rendezvous advertisements finished" - info "waku rendezvous advertisements finished" - - return ok() - -proc initialRequestAll*( - self: WakuRendezVous -): Future[Result[void, string]] {.async: (raises: []).} = - info "waku rendezvous initial requests started" - - let shards = self.getShards() - - let futs = collect(newSeq): - for shardId in shards: - let namespace = computeNamespace(self.clusterId, shardId) - # Get a random RDV peer for that shard - let rpi = self.peerManager.selectPeer( - RendezVousCodec, - some(toPubsubTopic(RelayShard(clusterId: self.clusterId, shardId: shardId))), - ).valueOr: - continue - - # Ask for peer records for that shard - self.batchRequest(namespace, PeersRequestedCount, @[rpi.peerId]) - - if futs.len < 1: - return err("could not get a peer supporting RendezVousCodec") - - let catchable = catch: - await allFinished(futs) - - catchable.isOkOr: - return err(error.msg) - - for fut in catchable.get(): - if fut.failed(): - warn "a rendezvous request failed", cause = fut.error.msg - elif fut.finished(): - let res = fut.value() - - let records = res.valueOr: - warn "a rendezvous request failed", cause = $error - continue - - for record in records: - rendezvousPeerFoundTotal.inc() - self.peerManager.addPeer(record) - - info "waku rendezvous initial request finished" - - return ok() + return res proc periodicRegistration(self: WakuRendezVous) {.async.} = info "waku rendezvous periodic registration started", @@ -237,22 +143,6 @@ proc periodicRegistration(self: WakuRendezVous) {.async.} = # Back to normal interval if no errors self.registrationInterval = DefaultRegistrationInterval -proc periodicRequests(self: WakuRendezVous) {.async.} = - info "waku rendezvous periodic requests started", interval = self.requestInterval - - # infinite loop - while true: - (await self.initialRequestAll()).isOkOr: - error "waku rendezvous requests failed", error = error - - await sleepAsync(self.requestInterval) - - # Exponential backoff - self.requestInterval += self.requestInterval - - if self.requestInterval >= 1.days: - break - proc new*( T: type WakuRendezVous, switch: Switch, @@ -260,46 +150,88 @@ proc new*( clusterId: uint16, getShards: GetShards, getCapabilities: GetCapabilities, + getPeerRecord: GetWakuPeerRecord, ): Result[T, string] {.raises: [].} = - let rvCatchable = catch: - RendezVous.new(switch = switch, minDuration = DefaultRegistrationTTL) + let rng = newRng() + let wrv = T( + rng: rng, + salt: string.fromBytes(generateBytes(rng[], 8)), + registered: initOffsettedSeq[RegisteredData](), + expiredDT: Moment.now() - 1.days, + sema: newAsyncSemaphore(SemaphoreDefaultSize), + minDuration: rendezvous.MinimumAcceptedDuration, + maxDuration: rendezvous.MaximumDuration, + minTTL: rendezvous.MinimumAcceptedDuration.seconds.uint64, + maxTTL: rendezvous.MaximumDuration.seconds.uint64, + peerRecordValidator: checkWakuPeerRecord, + ) - let rv = rvCatchable.valueOr: - return err(error.msg) - - let mountCatchable = catch: - switch.mount(rv) - - mountCatchable.isOkOr: - return err(error.msg) - - var wrv = WakuRendezVous() - wrv.rendezvous = rv wrv.peerManager = peerManager wrv.clusterId = clusterId wrv.getShards = getShards wrv.getCapabilities = getCapabilities wrv.registrationInterval = DefaultRegistrationInterval - wrv.requestInterval = DefaultRequestsInterval + wrv.getPeerRecord = getPeerRecord + wrv.switch = switch + wrv.codec = WakuRendezVousCodec + + proc handleStream( + conn: Connection, proto: string + ) {.async: (raises: [CancelledError]).} = + try: + let + buf = await conn.readLp(4096) + msg = Message.decode(buf).tryGet() + case msg.msgType + of MessageType.Register: + #TODO: override this to store peers registered with us in peerstore with their info as well. + await wrv.register(conn, msg.register.tryGet(), wrv.getPeerRecord()) + of MessageType.RegisterResponse: + trace "Got an unexpected Register Response", response = msg.registerResponse + of MessageType.Unregister: + wrv.unregister(conn, msg.unregister.tryGet()) + of MessageType.Discover: + await wrv.discover(conn, msg.discover.tryGet()) + of MessageType.DiscoverResponse: + trace "Got an unexpected Discover Response", response = msg.discoverResponse + except CancelledError as exc: + trace "cancelled rendezvous handler" + raise exc + except CatchableError as exc: + trace "exception in rendezvous handler", description = exc.msg + finally: + await conn.close() + + wrv.handler = handleStream info "waku rendezvous initialized", - clusterId = clusterId, shards = getShards(), capabilities = getCapabilities() + clusterId = clusterId, + shards = getShards(), + capabilities = getCapabilities(), + wakuPeerRecord = getPeerRecord() return ok(wrv) proc start*(self: WakuRendezVous) {.async: (raises: []).} = + # Start the parent GenericRendezVous (starts the register deletion loop) + if self.started: + warn "waku rendezvous already started" + return + try: + await procCall GenericRendezVous[WakuPeerRecord](self).start() + except CancelledError as exc: + error "failed to start GenericRendezVous", cause = exc.msg + return # start registering forever self.periodicRegistrationFut = self.periodicRegistration() - self.periodicRequestFut = self.periodicRequests() - info "waku rendezvous discovery started" proc stopWait*(self: WakuRendezVous) {.async: (raises: []).} = if not self.periodicRegistrationFut.isNil(): await self.periodicRegistrationFut.cancelAndWait() - if not self.periodicRequestFut.isNil(): - await self.periodicRequestFut.cancelAndWait() + # Stop the parent GenericRendezVous (stops the register deletion loop) + await GenericRendezVous[WakuPeerRecord](self).stop() info "waku rendezvous discovery stopped" diff --git a/waku/waku_rendezvous/waku_peer_record.nim b/waku/waku_rendezvous/waku_peer_record.nim new file mode 100644 index 000000000..d6e700eb5 --- /dev/null +++ b/waku/waku_rendezvous/waku_peer_record.nim @@ -0,0 +1,74 @@ +import std/times, sugar + +import + libp2p/[ + protocols/rendezvous, + signed_envelope, + multicodec, + multiaddress, + protobuf/minprotobuf, + peerid, + ] + +type WakuPeerRecord* = object + # Considering only mix as of now, but we can keep extending this to include all capabilities part of Waku ENR + peerId*: PeerId + seqNo*: uint64 + addresses*: seq[MultiAddress] + mixKey*: string + +proc payloadDomain*(T: typedesc[WakuPeerRecord]): string = + $multiCodec("libp2p-custom-peer-record") + +proc payloadType*(T: typedesc[WakuPeerRecord]): seq[byte] = + @[(byte) 0x30, (byte) 0x00, (byte) 0x00] + +proc init*( + T: typedesc[WakuPeerRecord], + peerId: PeerId, + seqNo = getTime().toUnix().uint64, + addresses: seq[MultiAddress], + mixKey: string, +): T = + WakuPeerRecord(peerId: peerId, seqNo: seqNo, addresses: addresses, mixKey: mixKey) + +proc decode*( + T: typedesc[WakuPeerRecord], buffer: seq[byte] +): Result[WakuPeerRecord, ProtoError] = + let pb = initProtoBuffer(buffer) + var record = WakuPeerRecord() + + ?pb.getRequiredField(1, record.peerId) + ?pb.getRequiredField(2, record.seqNo) + discard ?pb.getRepeatedField(3, record.addresses) + + if record.addresses.len == 0: + return err(ProtoError.RequiredFieldMissing) + + ?pb.getRequiredField(4, record.mixKey) + + return ok(record) + +proc encode*(record: WakuPeerRecord): seq[byte] = + var pb = initProtoBuffer() + + pb.write(1, record.peerId) + pb.write(2, record.seqNo) + + for address in record.addresses: + pb.write(3, address) + + pb.write(4, record.mixKey) + + pb.finish() + return pb.buffer + +proc checkWakuPeerRecord*( + _: WakuPeerRecord, spr: seq[byte], peerId: PeerId +): Result[void, string] {.gcsafe.} = + if spr.len == 0: + return err("Empty peer record") + let signedEnv = ?SignedPayload[WakuPeerRecord].decode(spr).mapErr(x => $x) + if signedEnv.data.peerId != peerId: + return err("Bad Peer ID") + return ok() From 088e3108c86085c2d3c072c29964ae96f285c6d2 Mon Sep 17 00:00:00 2001 From: Prem Chaitanya Prathi Date: Sat, 22 Nov 2025 08:11:05 +0530 Subject: [PATCH 06/18] use exit==dest approach for mix (#3642) --- Makefile | 3 +++ apps/chat2mix/chat2mix.nim | 7 +++++-- examples/lightpush_mix/lightpush_publisher_mix.nim | 5 ++++- simulations/mixnet/config.toml | 4 ++-- .../mixnet/{run_lp_service_node.sh => run_mix_node.sh} | 0 waku/node/kernel_api/lightpush.nim | 2 +- waku/waku_mix/protocol.nim | 10 +++++----- waku/waku_rendezvous/protocol.nim | 3 +++ 8 files changed, 23 insertions(+), 11 deletions(-) rename simulations/mixnet/{run_lp_service_node.sh => run_mix_node.sh} (100%) diff --git a/Makefile b/Makefile index 029313c99..f65daff71 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,9 @@ ifeq ($(USE_LIBBACKTRACE), 0) NIM_PARAMS := $(NIM_PARAMS) -d:disable_libbacktrace endif +# enable experimental exit is dest feature in libp2p mix +NIM_PARAMS := $(NIM_PARAMS) -d:libp2p_mix_experimental_exit_is_dest + libbacktrace: + $(MAKE) -C vendor/nim-libbacktrace --no-print-directory BUILD_CXX_LIB=0 diff --git a/apps/chat2mix/chat2mix.nim b/apps/chat2mix/chat2mix.nim index 3fdd7bc9c..45fd1fa2d 100644 --- a/apps/chat2mix/chat2mix.nim +++ b/apps/chat2mix/chat2mix.nim @@ -82,6 +82,8 @@ type PrivateKey* = crypto.PrivateKey Topic* = waku_core.PubsubTopic +const MinMixNodePoolSize = 4 + ##################### ## chat2 protobufs ## ##################### @@ -592,6 +594,7 @@ proc processInput(rfd: AsyncFD, rng: ref HmacDrbgContext) {.async.} = node.peerManager.addServicePeer(servicePeerInfo, WakuLightpushCodec) node.peerManager.addServicePeer(servicePeerInfo, WakuPeerExchangeCodec) + #node.peerManager.addServicePeer(servicePeerInfo, WakuRendezVousCodec) # Start maintaining subscription asyncSpawn maintainSubscription( @@ -599,12 +602,12 @@ proc processInput(rfd: AsyncFD, rng: ref HmacDrbgContext) {.async.} = ) echo "waiting for mix nodes to be discovered..." while true: - if node.getMixNodePoolSize() >= 3: + if node.getMixNodePoolSize() >= MinMixNodePoolSize: break discard await node.fetchPeerExchangePeers() await sleepAsync(1000) - while node.getMixNodePoolSize() < 3: + while node.getMixNodePoolSize() < MinMixNodePoolSize: info "waiting for mix nodes to be discovered", currentpoolSize = node.getMixNodePoolSize() await sleepAsync(1000) diff --git a/examples/lightpush_mix/lightpush_publisher_mix.nim b/examples/lightpush_mix/lightpush_publisher_mix.nim index bb4bb4c4e..4219cd665 100644 --- a/examples/lightpush_mix/lightpush_publisher_mix.nim +++ b/examples/lightpush_mix/lightpush_publisher_mix.nim @@ -144,7 +144,7 @@ proc setupAndPublish(rng: ref HmacDrbgContext, conf: LightPushMixConf) {.async.} conn = connOpt.get() else: conn = node.wakuMix.toConnection( - MixDestination.init(dPeerId, pxPeerInfo.addrs[0]), # destination lightpush peer + MixDestination.exitNode(dPeerId), # destination lightpush peer WakuLightPushCodec, # protocol codec which will be used over the mix connection MixParameters(expectReply: Opt.some(true), numSurbs: Opt.some(byte(1))), # mix parameters indicating we expect a single reply @@ -163,6 +163,9 @@ proc setupAndPublish(rng: ref HmacDrbgContext, conf: LightPushMixConf) {.async.} ephemeral: true, # tell store nodes to not store it timestamp: getNowInNanosecondTime(), ) # current timestamp + let res = await node.wakuLightpushClient.publishWithConn( + LightpushPubsubTopic, message, conn, dPeerId + ) let startTime = getNowInNanosecondTime() diff --git a/simulations/mixnet/config.toml b/simulations/mixnet/config.toml index 17e9242d3..3719d8177 100644 --- a/simulations/mixnet/config.toml +++ b/simulations/mixnet/config.toml @@ -1,6 +1,6 @@ log-level = "INFO" relay = true -#mix = true +mix = true filter = true store = false lightpush = true @@ -18,7 +18,7 @@ num-shards-in-network = 1 shard = [0] agent-string = "nwaku-mix" nodekey = "f98e3fba96c32e8d1967d460f1b79457380e1a895f7971cecc8528abe733781a" -#mixkey = "a87db88246ec0eedda347b9b643864bee3d6933eb15ba41e6d58cb678d813258" +mixkey = "a87db88246ec0eedda347b9b643864bee3d6933eb15ba41e6d58cb678d813258" rendezvous = true listen-address = "127.0.0.1" nat = "extip:127.0.0.1" diff --git a/simulations/mixnet/run_lp_service_node.sh b/simulations/mixnet/run_mix_node.sh similarity index 100% rename from simulations/mixnet/run_lp_service_node.sh rename to simulations/mixnet/run_mix_node.sh diff --git a/waku/node/kernel_api/lightpush.nim b/waku/node/kernel_api/lightpush.nim index f42cb146e..8df6291b1 100644 --- a/waku/node/kernel_api/lightpush.nim +++ b/waku/node/kernel_api/lightpush.nim @@ -199,7 +199,7 @@ proc lightpushPublishHandler( if mixify: #indicates we want to use mix to send the message #TODO: How to handle multiple addresses? let conn = node.wakuMix.toConnection( - MixDestination.init(peer.peerId, peer.addrs[0]), + 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 diff --git a/waku/waku_mix/protocol.nim b/waku/waku_mix/protocol.nim index d3d765df8..366d5da91 100644 --- a/waku/waku_mix/protocol.nim +++ b/waku/waku_mix/protocol.nim @@ -21,7 +21,7 @@ import logScope: topics = "waku mix" -const mixMixPoolSize = 3 +const minMixPoolSize = 4 type WakuMix* = ref object of MixProtocol @@ -181,12 +181,12 @@ proc new*( peermgr.switch.peerInfo.peerId, nodeMultiAddr, mixPubKey, mixPrivKey, peermgr.switch.peerInfo.publicKey.skkey, peermgr.switch.peerInfo.privateKey.skkey, ) - if bootnodes.len < mixMixPoolSize: - warn "publishing with mix won't work until there are 3 mix nodes in node pool" + if bootnodes.len < minMixPoolSize: + warn "publishing with mix won't work until atleast 3 mix nodes in node pool" let initTable = processBootNodes(bootnodes, peermgr) - if len(initTable) < mixMixPoolSize: - warn "publishing with mix won't work until there are 3 mix nodes in node pool" + if len(initTable) < minMixPoolSize: + warn "publishing with mix won't work until atleast 3 mix nodes in node pool" var m = WakuMix(peerManager: peermgr, clusterId: clusterId, pubKey: mixPubKey) procCall MixProtocol(m).init(localMixNodeInfo, initTable, peermgr.switch) return ok(m) diff --git a/waku/waku_rendezvous/protocol.nim b/waku/waku_rendezvous/protocol.nim index ed414fa42..7b97375ff 100644 --- a/waku/waku_rendezvous/protocol.nim +++ b/waku/waku_rendezvous/protocol.nim @@ -234,4 +234,7 @@ proc stopWait*(self: WakuRendezVous) {.async: (raises: []).} = # Stop the parent GenericRendezVous (stops the register deletion loop) await GenericRendezVous[WakuPeerRecord](self).stop() + # Stop the parent GenericRendezVous (stops the register deletion loop) + await GenericRendezVous[WakuPeerRecord](self).stop() + info "waku rendezvous discovery stopped" From 454b098ac52df75e5d5de5010f9edb42cf8b0d52 Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:16:37 +0100 Subject: [PATCH 07/18] new metric in postgres_driver to estimate payload stats (#3596) --- .../driver/postgres_driver/postgres_driver.nim | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/waku/waku_archive/driver/postgres_driver/postgres_driver.nim b/waku/waku_archive/driver/postgres_driver/postgres_driver.nim index 842d7cbc2..9b0e14c84 100644 --- a/waku/waku_archive/driver/postgres_driver/postgres_driver.nim +++ b/waku/waku_archive/driver/postgres_driver/postgres_driver.nim @@ -5,6 +5,7 @@ import stew/[byteutils, arrayops], results, chronos, + metrics, db_connector/[postgres, db_common], chronicles import @@ -16,6 +17,9 @@ import ./postgres_healthcheck, ./partitions_manager +declarePublicGauge postgres_payload_size_bytes, + "Payload size in bytes of correctly stored messages" + type PostgresDriver* = ref object of ArchiveDriver ## Establish a separate pools for read/write operations writeConnPool: PgAsyncPool @@ -333,7 +337,7 @@ method put*( return err("could not put msg in messages table: " & $error) ## Now add the row to messages_lookup - return await s.writeConnPool.runStmt( + let ret = await s.writeConnPool.runStmt( InsertRowInMessagesLookupStmtName, InsertRowInMessagesLookupStmtDefinition, @[messageHash, timestamp], @@ -341,6 +345,10 @@ method put*( @[int32(0), int32(0)], ) + if ret.isOk(): + postgres_payload_size_bytes.set(message.payload.len) + return ret + method getAllMessages*( s: PostgresDriver ): Future[ArchiveDriverResult[seq[ArchiveRow]]] {.async.} = From c0a7debfd157a158c7973fc1135150df804f97e5 Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:05:40 +0100 Subject: [PATCH 08/18] Adapt makefile for libwaku windows (#3648) --- Makefile | 6 +++- scripts/libwaku_windows_setup.mk | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 scripts/libwaku_windows_setup.mk diff --git a/Makefile b/Makefile index f65daff71..2f15ccd71 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,9 @@ ifeq ($(detected_OS),Windows) LIBS = -lws2_32 -lbcrypt -liphlpapi -luserenv -lntdll -lminiupnpc -lnatpmp -lpq NIM_PARAMS += $(foreach lib,$(LIBS),--passL:"$(lib)") + + export PATH := /c/msys64/usr/bin:/c/msys64/mingw64/bin:/c/msys64/usr/lib:/c/msys64/mingw64/lib:$(PATH) + endif ########## @@ -424,13 +427,13 @@ docker-liteprotocoltester-push: STATIC ?= 0 - libwaku: | build deps librln rm -f build/libwaku* ifeq ($(STATIC), 1) echo -e $(BUILD_MSG) "build/$@.a" && $(ENV_SCRIPT) nim libwakuStatic $(NIM_PARAMS) waku.nims else ifeq ($(detected_OS),Windows) + make -f scripts/libwaku_windows_setup.mk windows-setup echo -e $(BUILD_MSG) "build/$@.dll" && $(ENV_SCRIPT) nim libwakuDynamic $(NIM_PARAMS) waku.nims else echo -e $(BUILD_MSG) "build/$@.so" && $(ENV_SCRIPT) nim libwakuDynamic $(NIM_PARAMS) waku.nims @@ -546,3 +549,4 @@ release-notes: sed -E 's@#([0-9]+)@[#\1](https://github.com/waku-org/nwaku/issues/\1)@g' # I could not get the tool to replace issue ids with links, so using sed for now, # asked here: https://github.com/bvieira/sv4git/discussions/101 + diff --git a/scripts/libwaku_windows_setup.mk b/scripts/libwaku_windows_setup.mk new file mode 100644 index 000000000..503d0c405 --- /dev/null +++ b/scripts/libwaku_windows_setup.mk @@ -0,0 +1,53 @@ +# --------------------------------------------------------- +# Windows Setup Makefile +# --------------------------------------------------------- + +# Extend PATH (Make preserves environment variables) +export PATH := /c/msys64/usr/bin:/c/msys64/mingw64/bin:/c/msys64/usr/lib:/c/msys64/mingw64/lib:$(PATH) + +# Tools required +DEPS = gcc g++ make cmake cargo upx rustc python + +# Default target +.PHONY: windows-setup +windows-setup: check-deps update-submodules create-tmp libunwind miniupnpc libnatpmp + @echo "Windows setup completed successfully!" + +.PHONY: check-deps +check-deps: + @echo "Checking libwaku build dependencies..." + @for dep in $(DEPS); do \ + if ! which $$dep >/dev/null 2>&1; then \ + echo "✗ Missing dependency: $$dep"; \ + exit 1; \ + else \ + echo "✓ Found: $$dep"; \ + fi; \ + done + +.PHONY: update-submodules +update-submodules: + @echo "Updating libwaku git submodules..." + git submodule update --init --recursive + +.PHONY: create-tmp +create-tmp: + @echo "Creating tmp directory..." + mkdir -p tmp + +.PHONY: libunwind +libunwind: + @echo "Building libunwind..." + cd vendor/nim-libbacktrace && make all V=1 + +.PHONY: miniupnpc +miniupnpc: + @echo "Building miniupnpc..." + cd vendor/nim-nat-traversal/vendor/miniupnp/miniupnpc && \ + make -f Makefile.mingw CC=gcc CXX=g++ libminiupnpc.a V=1 + +.PHONY: libnatpmp +libnatpmp: + @echo "Building libnatpmp..." + cd vendor/nim-nat-traversal/vendor/libnatpmp-upstream && \ + make CC="gcc -fPIC -D_WIN32_WINNT=0x0600 -DNATPMP_STATICLIB" libnatpmp.a V=1 From 1e73213a3604b0113a13b1ca2157db3276c78a4d Mon Sep 17 00:00:00 2001 From: Sergei Tikhomirov Date: Fri, 28 Nov 2025 10:41:20 +0100 Subject: [PATCH 09/18] chore: Lightpush minor refactor (#3538) * chore: refactor Lightpush (more DRY) * chore: apply review suggestions Co-authored-by: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> --------- Co-authored-by: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> --- .../lightpush_mix/lightpush_publisher_mix.nim | 6 +- tests/waku_lightpush/test_ratelimit.nim | 8 +- waku/node/kernel_api/lightpush.nim | 4 +- waku/waku_lightpush/client.nim | 145 +++++++----------- waku/waku_lightpush/common.nim | 15 +- waku/waku_lightpush/protocol.nim | 12 +- 6 files changed, 76 insertions(+), 114 deletions(-) diff --git a/examples/lightpush_mix/lightpush_publisher_mix.nim b/examples/lightpush_mix/lightpush_publisher_mix.nim index 4219cd665..104de8552 100644 --- a/examples/lightpush_mix/lightpush_publisher_mix.nim +++ b/examples/lightpush_mix/lightpush_publisher_mix.nim @@ -163,9 +163,9 @@ proc setupAndPublish(rng: ref HmacDrbgContext, conf: LightPushMixConf) {.async.} ephemeral: true, # tell store nodes to not store it timestamp: getNowInNanosecondTime(), ) # current timestamp - let res = await node.wakuLightpushClient.publishWithConn( - LightpushPubsubTopic, message, conn, dPeerId - ) + + let res = + await node.wakuLightpushClient.publish(some(LightpushPubsubTopic), message, conn) let startTime = getNowInNanosecondTime() diff --git a/tests/waku_lightpush/test_ratelimit.nim b/tests/waku_lightpush/test_ratelimit.nim index b2dcdc7b5..7420a4e56 100644 --- a/tests/waku_lightpush/test_ratelimit.nim +++ b/tests/waku_lightpush/test_ratelimit.nim @@ -37,7 +37,7 @@ suite "Rate limited push service": handlerFuture = newFuture[(string, WakuMessage)]() let requestRes = - await client.publish(some(DefaultPubsubTopic), message, peer = serverPeerId) + await client.publish(some(DefaultPubsubTopic), message, serverPeerId) check await handlerFuture.withTimeout(50.millis) @@ -66,7 +66,7 @@ suite "Rate limited push service": var endTime = Moment.now() var elapsed: Duration = (endTime - startTime) await sleepAsync(tokenPeriod - elapsed + firstWaitExtend) - firstWaitEXtend = 100.millis + firstWaitExtend = 100.millis ## Cleanup await allFutures(clientSwitch.stop(), serverSwitch.stop()) @@ -99,7 +99,7 @@ suite "Rate limited push service": let message = fakeWakuMessage() handlerFuture = newFuture[(string, WakuMessage)]() let requestRes = - await client.publish(some(DefaultPubsubTopic), message, peer = serverPeerId) + await client.publish(some(DefaultPubsubTopic), message, serverPeerId) discard await handlerFuture.withTimeout(10.millis) check: @@ -114,7 +114,7 @@ suite "Rate limited push service": let message = fakeWakuMessage() handlerFuture = newFuture[(string, WakuMessage)]() let requestRes = - await client.publish(some(DefaultPubsubTopic), message, peer = serverPeerId) + await client.publish(some(DefaultPubsubTopic), message, serverPeerId) discard await handlerFuture.withTimeout(10.millis) check: diff --git a/waku/node/kernel_api/lightpush.nim b/waku/node/kernel_api/lightpush.nim index 8df6291b1..9451767ac 100644 --- a/waku/node/kernel_api/lightpush.nim +++ b/waku/node/kernel_api/lightpush.nim @@ -210,9 +210,7 @@ proc lightpushPublishHandler( "Waku lightpush with mix not available", ) - return await node.wakuLightpushClient.publishWithConn( - pubsubTopic, message, conn, peer.peerId - ) + return await node.wakuLightpushClient.publish(some(pubsubTopic), message, conn) else: return await node.wakuLightpushClient.publish(some(pubsubTopic), message, peer) diff --git a/waku/waku_lightpush/client.nim b/waku/waku_lightpush/client.nim index d68552304..b528b4c76 100644 --- a/waku/waku_lightpush/client.nim +++ b/waku/waku_lightpush/client.nim @@ -17,8 +17,8 @@ logScope: topics = "waku lightpush client" type WakuLightPushClient* = ref object - peerManager*: PeerManager rng*: ref rand.HmacDrbgContext + peerManager*: PeerManager publishObservers: seq[PublishObserver] proc new*( @@ -29,33 +29,31 @@ proc new*( proc addPublishObserver*(wl: WakuLightPushClient, obs: PublishObserver) = wl.publishObservers.add(obs) -proc sendPushRequest( - wl: WakuLightPushClient, - req: LightPushRequest, - peer: PeerId | RemotePeerInfo, - conn: Option[Connection] = none(Connection), -): Future[WakuLightPushResult] {.async.} = - let connection = conn.valueOr: - (await wl.peerManager.dialPeer(peer, WakuLightPushCodec)).valueOr: - waku_lightpush_v3_errors.inc(labelValues = [dialFailure]) - return lighpushErrorResult( - LightPushErrorCode.NO_PEERS_TO_RELAY, - dialFailure & ": " & $peer & " is not accessible", - ) +proc ensureTimestampSet(message: var WakuMessage) = + if message.timestamp == 0: + message.timestamp = getNowInNanosecondTime() - defer: - await connection.closeWithEOF() +## Short log string for peer identifiers (overloads for convenience) +func shortPeerId(peer: PeerId): string = + shortLog(peer) + +func shortPeerId(peer: RemotePeerInfo): string = + shortLog(peer.peerId) + +proc sendPushRequestToConn( + wl: WakuLightPushClient, request: LightPushRequest, conn: Connection +): Future[WakuLightPushResult] {.async.} = try: - await connection.writeLP(req.encode().buffer) - except CatchableError: - error "failed to send push request", error = getCurrentExceptionMsg() + await conn.writeLp(request.encode().buffer) + except LPStreamRemoteClosedError: + error "Failed to write request to peer", error = getCurrentExceptionMsg() return lightpushResultInternalError( - "failed to send push request: " & getCurrentExceptionMsg() + "Failed to write request to peer: " & getCurrentExceptionMsg() ) var buffer: seq[byte] try: - buffer = await connection.readLp(DefaultMaxRpcSize.int) + buffer = await conn.readLp(DefaultMaxRpcSize.int) except LPStreamRemoteClosedError: error "Failed to read response from peer", error = getCurrentExceptionMsg() return lightpushResultInternalError( @@ -66,10 +64,12 @@ proc sendPushRequest( waku_lightpush_v3_errors.inc(labelValues = [decodeRpcFailure]) return lightpushResultInternalError(decodeRpcFailure) - if response.requestId != req.requestId and - response.statusCode != LightPushErrorCode.TOO_MANY_REQUESTS: + let requestIdMismatch = response.requestId != request.requestId + let tooManyRequests = response.statusCode == LightPushErrorCode.TOO_MANY_REQUESTS + if requestIdMismatch and (not tooManyRequests): + # response with TOO_MANY_REQUESTS error code has no requestId by design error "response failure, requestId mismatch", - requestId = req.requestId, responseRequestId = response.requestId + requestId = request.requestId, responseRequestId = response.requestId return lightpushResultInternalError("response failure, requestId mismatch") return toPushResult(response) @@ -78,88 +78,49 @@ proc publish*( wl: WakuLightPushClient, pubSubTopic: Option[PubsubTopic] = none(PubsubTopic), wakuMessage: WakuMessage, - peer: PeerId | RemotePeerInfo, + dest: Connection | PeerId | RemotePeerInfo, ): Future[WakuLightPushResult] {.async, gcsafe.} = + let conn = + when dest is Connection: + dest + else: + (await wl.peerManager.dialPeer(dest, WakuLightPushCodec)).valueOr: + waku_lightpush_v3_errors.inc(labelValues = [dialFailure]) + return lighpushErrorResult( + LightPushErrorCode.NO_PEERS_TO_RELAY, + "Peer is not accessible: " & dialFailure & " - " & $dest, + ) + + defer: + await conn.closeWithEOF() + var message = wakuMessage - if message.timestamp == 0: - message.timestamp = getNowInNanosecondTime() + ensureTimestampSet(message) - when peer is PeerId: - info "publish", - peerId = shortLog(peer), - msg_hash = computeMessageHash(pubsubTopic.get(""), message).to0xHex - else: - info "publish", - peerId = shortLog(peer.peerId), - msg_hash = computeMessageHash(pubsubTopic.get(""), message).to0xHex + let msgHash = computeMessageHash(pubSubTopic.get(""), message).to0xHex() + info "publish", + myPeerId = wl.peerManager.switch.peerInfo.peerId, + peerId = shortPeerId(conn.peerId), + msgHash = msgHash, + sentTime = getNowInNanosecondTime() - let pushRequest = LightpushRequest( - requestId: generateRequestId(wl.rng), pubSubTopic: pubSubTopic, message: message + let request = LightpushRequest( + requestId: generateRequestId(wl.rng), pubsubTopic: pubSubTopic, message: message ) - let publishedCount = ?await wl.sendPushRequest(pushRequest, peer) + let relayPeerCount = ?await wl.sendPushRequestToConn(request, conn) for obs in wl.publishObservers: obs.onMessagePublished(pubSubTopic.get(""), message) - return lightpushSuccessResult(publishedCount) + return lightpushSuccessResult(relayPeerCount) proc publishToAny*( - wl: WakuLightPushClient, pubSubTopic: PubsubTopic, wakuMessage: WakuMessage + wl: WakuLightPushClient, pubsubTopic: PubsubTopic, wakuMessage: WakuMessage ): Future[WakuLightPushResult] {.async, gcsafe.} = - ## This proc is similar to the publish one but in this case - ## we don't specify a particular peer and instead we get it from peer manager - - var message = wakuMessage - if message.timestamp == 0: - message.timestamp = getNowInNanosecondTime() - + # Like publish, but selects a peer automatically from the peer manager let peer = wl.peerManager.selectPeer(WakuLightPushCodec).valueOr: # TODO: check if it is matches the situation - shall we distinguish client side missing peers from server side? return lighpushErrorResult( LightPushErrorCode.NO_PEERS_TO_RELAY, "no suitable remote peers" ) - - info "publishToAny", - my_peer_id = wl.peerManager.switch.peerInfo.peerId, - peer_id = peer.peerId, - msg_hash = computeMessageHash(pubsubTopic, message).to0xHex, - sentTime = getNowInNanosecondTime() - - let pushRequest = LightpushRequest( - requestId: generateRequestId(wl.rng), - pubSubTopic: some(pubSubTopic), - message: message, - ) - let publishedCount = ?await wl.sendPushRequest(pushRequest, peer) - - for obs in wl.publishObservers: - obs.onMessagePublished(pubSubTopic, message) - - return lightpushSuccessResult(publishedCount) - -proc publishWithConn*( - wl: WakuLightPushClient, - pubSubTopic: PubsubTopic, - message: WakuMessage, - conn: Connection, - destPeer: PeerId, -): Future[WakuLightPushResult] {.async, gcsafe.} = - info "publishWithConn", - my_peer_id = wl.peerManager.switch.peerInfo.peerId, - peer_id = destPeer, - msg_hash = computeMessageHash(pubsubTopic, message).to0xHex, - sentTime = getNowInNanosecondTime() - - let pushRequest = LightpushRequest( - requestId: generateRequestId(wl.rng), - pubSubTopic: some(pubSubTopic), - message: message, - ) - #TODO: figure out how to not pass destPeer as this is just a hack - let publishedCount = - ?await wl.sendPushRequest(pushRequest, destPeer, conn = some(conn)) - - for obs in wl.publishObservers: - obs.onMessagePublished(pubSubTopic, message) - - return lightpushSuccessResult(publishedCount) + return await wl.publish(some(pubsubTopic), wakuMessage, peer) diff --git a/waku/waku_lightpush/common.nim b/waku/waku_lightpush/common.nim index f2687834e..9c2ea7ced 100644 --- a/waku/waku_lightpush/common.nim +++ b/waku/waku_lightpush/common.nim @@ -35,7 +35,15 @@ func isSuccess*(response: LightPushResponse): bool = func toPushResult*(response: LightPushResponse): WakuLightPushResult = if isSuccess(response): - return ok(response.relayPeerCount.get(0)) + let relayPeerCount = response.relayPeerCount.get(0) + return ( + if (relayPeerCount == 0): + # Consider publishing to zero peers an error even if the service node + # sent us a "successful" response with zero peers + err((LightPushErrorCode.NO_PEERS_TO_RELAY, response.statusDesc)) + else: + ok(relayPeerCount) + ) else: return err((response.statusCode, response.statusDesc)) @@ -51,11 +59,6 @@ func lightpushResultBadRequest*(msg: string): WakuLightPushResult = func lightpushResultServiceUnavailable*(msg: string): WakuLightPushResult = return err((LightPushErrorCode.SERVICE_NOT_AVAILABLE, some(msg))) -func lighpushErrorResult*( - statusCode: LightpushStatusCode, desc: Option[string] -): WakuLightPushResult = - return err((statusCode, desc)) - func lighpushErrorResult*( statusCode: LightpushStatusCode, desc: string ): WakuLightPushResult = diff --git a/waku/waku_lightpush/protocol.nim b/waku/waku_lightpush/protocol.nim index 2e8c9c2f1..95bfc003e 100644 --- a/waku/waku_lightpush/protocol.nim +++ b/waku/waku_lightpush/protocol.nim @@ -78,9 +78,9 @@ proc handleRequest( proc handleRequest*( wl: WakuLightPush, peerId: PeerId, buffer: seq[byte] ): Future[LightPushResponse] {.async.} = - let pushRequest = LightPushRequest.decode(buffer).valueOr: + let request = LightPushRequest.decode(buffer).valueOr: let desc = decodeRpcFailure & ": " & $error - error "failed to push message", error = desc + error "failed to decode Lightpush request", error = desc let errorCode = LightPushErrorCode.BAD_REQUEST waku_lightpush_v3_errors.inc(labelValues = [$errorCode]) return LightPushResponse( @@ -89,16 +89,16 @@ proc handleRequest*( statusDesc: some(desc), ) - let relayPeerCount = (await handleRequest(wl, peerId, pushRequest)).valueOr: + let relayPeerCount = (await wl.handleRequest(peerId, request)).valueOr: let desc = error.desc waku_lightpush_v3_errors.inc(labelValues = [$error.code]) error "failed to push message", error = desc return LightPushResponse( - requestId: pushRequest.requestId, statusCode: error.code, statusDesc: desc + requestId: request.requestId, statusCode: error.code, statusDesc: desc ) return LightPushResponse( - requestId: pushRequest.requestId, + requestId: request.requestId, statusCode: LightPushSuccessCode.SUCCESS, statusDesc: none[string](), relayPeerCount: some(relayPeerCount), @@ -123,7 +123,7 @@ proc initProtocolHandler(wl: WakuLightPush) = ) try: - rpc = await handleRequest(wl, conn.peerId, buffer) + rpc = await wl.handleRequest(conn.peerId, buffer) except CatchableError: error "lightpush failed handleRequest", error = getCurrentExceptionMsg() do: From c6cf34df067a1362207a815e2363d033367abac8 Mon Sep 17 00:00:00 2001 From: Fabiana Cecin Date: Fri, 28 Nov 2025 14:20:36 -0300 Subject: [PATCH 10/18] feat(tests): robustify waku_rln_relay test utils (#3650) --- tests/waku_rln_relay/utils_onchain.nim | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/waku_rln_relay/utils_onchain.nim b/tests/waku_rln_relay/utils_onchain.nim index 85f627aa0..06e4fcdcf 100644 --- a/tests/waku_rln_relay/utils_onchain.nim +++ b/tests/waku_rln_relay/utils_onchain.nim @@ -82,6 +82,10 @@ proc getForgePath(): string = forgePath = joinPath(forgePath, ".foundry/bin/forge") return $forgePath +template execForge(cmd: string): tuple[output: string, exitCode: int] = + # unset env vars that affect e.g. "forge script" before running forge + execCmdEx("unset ETH_FROM ETH_PASSWORD && " & cmd) + contract(ERC20Token): proc allowance(owner: Address, spender: Address): UInt256 {.view.} proc balanceOf(account: Address): UInt256 {.view.} @@ -225,11 +229,14 @@ proc deployTestToken*( # Deploy TestToken contract let forgeCmdTestToken = fmt"""cd {submodulePath} && {forgePath} script test/TestToken.sol --broadcast -vvv --rpc-url http://localhost:8540 --tc TestTokenFactory --private-key {pk} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json""" - let (outputDeployTestToken, exitCodeDeployTestToken) = execCmdEx(forgeCmdTestToken) + let (outputDeployTestToken, exitCodeDeployTestToken) = execForge(forgeCmdTestToken) trace "Executed forge command to deploy TestToken contract", output = outputDeployTestToken if exitCodeDeployTestToken != 0: - return error("Forge command to deploy TestToken contract failed") + error "Forge command to deploy TestToken contract failed", + error = outputDeployTestToken + return + err("Forge command to deploy TestToken contract failed: " & outputDeployTestToken) # Parse the command output to find contract address let testTokenAddress = getContractAddressFromDeployScriptOutput(outputDeployTestToken).valueOr: @@ -351,7 +358,7 @@ proc executeForgeContractDeployScripts*( let forgeCmdPriceCalculator = fmt"""cd {submodulePath} && {forgePath} script script/Deploy.s.sol --broadcast -vvvv --rpc-url http://localhost:8540 --tc DeployPriceCalculator --private-key {privateKey} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json""" let (outputDeployPriceCalculator, exitCodeDeployPriceCalculator) = - execCmdEx(forgeCmdPriceCalculator) + execForge(forgeCmdPriceCalculator) trace "Executed forge command to deploy LinearPriceCalculator contract", output = outputDeployPriceCalculator if exitCodeDeployPriceCalculator != 0: @@ -368,7 +375,7 @@ proc executeForgeContractDeployScripts*( let forgeCmdWakuRln = fmt"""cd {submodulePath} && {forgePath} script script/Deploy.s.sol --broadcast -vvvv --rpc-url http://localhost:8540 --tc DeployWakuRlnV2 --private-key {privateKey} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json""" - let (outputDeployWakuRln, exitCodeDeployWakuRln) = execCmdEx(forgeCmdWakuRln) + let (outputDeployWakuRln, exitCodeDeployWakuRln) = execForge(forgeCmdWakuRln) trace "Executed forge command to deploy WakuRlnV2 contract", output = outputDeployWakuRln if exitCodeDeployWakuRln != 0: @@ -388,7 +395,7 @@ proc executeForgeContractDeployScripts*( # Deploy Proxy contract let forgeCmdProxy = fmt"""cd {submodulePath} && {forgePath} script script/Deploy.s.sol --broadcast -vvvv --rpc-url http://localhost:8540 --tc DeployProxy --private-key {privateKey} && rm -rf broadcast/*/*/run-1*.json && rm -rf cache/*/*/run-1*.json""" - let (outputDeployProxy, exitCodeDeployProxy) = execCmdEx(forgeCmdProxy) + let (outputDeployProxy, exitCodeDeployProxy) = execForge(forgeCmdProxy) trace "Executed forge command to deploy proxy contract", output = outputDeployProxy if exitCodeDeployProxy != 0: error "Forge command to deploy Proxy failed", error = outputDeployProxy @@ -503,7 +510,7 @@ proc runAnvil*(port: int = 8540, chainId: string = "1234"): Process = "--chain-id", $chainId, ], - options = {poUsePath}, + options = {poUsePath, poStdErrToStdOut}, ) let anvilPID = runAnvil.processID @@ -516,7 +523,13 @@ proc runAnvil*(port: int = 8540, chainId: string = "1234"): Process = anvilStartLog.add(cmdline) if cmdline.contains("Listening on 127.0.0.1:" & $port): break + else: + error "Anvil daemon exited (closed output)", + pid = anvilPID, startLog = anvilStartLog + return except Exception, CatchableError: + warn "Anvil daemon stdout reading error; assuming it started OK", + pid = anvilPID, startLog = anvilStartLog, err = getCurrentExceptionMsg() break info "Anvil daemon is running and ready", pid = anvilPID, startLog = anvilStartLog return runAnvil From 7eb1fdb0ac3eed0b39c5d1456ba6d1b9881e7980 Mon Sep 17 00:00:00 2001 From: Darshan K <35736874+darshankabariya@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:03:59 +0530 Subject: [PATCH 11/18] chore: new release process ( beta and full ) (#3647) --- .../ISSUE_TEMPLATE/prepare_beta_release.md | 56 ++++++++ .../ISSUE_TEMPLATE/prepare_full_release.md | 76 +++++++++++ .github/ISSUE_TEMPLATE/prepare_release.md | 72 ---------- docs/contributors/release-process.md | 124 ++++++++++++------ 4 files changed, 213 insertions(+), 115 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/prepare_beta_release.md create mode 100644 .github/ISSUE_TEMPLATE/prepare_full_release.md delete mode 100644 .github/ISSUE_TEMPLATE/prepare_release.md diff --git a/.github/ISSUE_TEMPLATE/prepare_beta_release.md b/.github/ISSUE_TEMPLATE/prepare_beta_release.md new file mode 100644 index 000000000..270f6a8e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/prepare_beta_release.md @@ -0,0 +1,56 @@ +--- +name: Prepare Beta Release +about: Execute tasks for the creation and publishing of a new beta release +title: 'Prepare beta release 0.0.0' +labels: beta-release +assignees: '' + +--- + + + +### Items to complete + +All items below are to be completed by the owner of the given release. + +- [ ] Create release branch with major and minor only ( e.g. release/v0.X ) if it doesn't exist. +- [ ] Assign release candidate tag to the release branch HEAD (e.g. `v0.X.0-beta-rc.0`, `v0.X.0-beta-rc.1`, ... `v0.X.0-beta-rc.N`). +- [ ] Generate and edit release notes in CHANGELOG.md. + +- [ ] **Waku test and fleets validation** + - [ ] Ensure all the unit tests (specifically js-waku tests) are green against the release candidate. + - [ ] Deploy the release candidate to `waku.test` only through [deploy-waku-test job](https://ci.infra.status.im/job/nim-waku/job/deploy-waku-test/) and wait for it to finish (Jenkins access required; ask the infra team if you don't have it). + - After completion, disable [deployment job](https://ci.infra.status.im/job/nim-waku/) so that its version is not updated on every merge to master. + - Verify the deployed version at https://fleets.waku.org/. + - Confirm the container image exists on [Harbor](https://harbor.status.im/harbor/projects/9/repositories/nwaku/artifacts-tab). + - [ ] Analyze Kibana logs from the previous month (since the last release was deployed) for possible crashes or errors in `waku.test`. + - Most relevant logs are `(fleet: "waku.test" AND message: "SIGSEGV")`. + - [ ] Enable again the `waku.test` fleet to resume auto-deployment of the latest `master` commit. + +- [ ] **Proceed with release** + + - [ ] Assign a final release tag (`v0.X.0-beta`) to the same commit that contains the validated release-candidate tag (e.g. `v0.X.0-beta-rc.N`) and submit a PR from the release branch to `master`. + - [ ] Update [nwaku-compose](https://github.com/waku-org/nwaku-compose) and [waku-simulator](https://github.com/waku-org/waku-simulator) according to the new release. + - [ ] Bump nwaku dependency in [waku-rust-bindings](https://github.com/waku-org/waku-rust-bindings) and make sure all examples and tests work. + - [ ] Bump nwaku dependency in [waku-go-bindings](https://github.com/waku-org/waku-go-bindings) and make sure all tests work. + - [ ] Create GitHub release (https://github.com/waku-org/nwaku/releases). + - [ ] Submit a PR to merge the release branch back to `master`. Make sure you use the option "Merge pull request (Create a merge commit)" to perform the merge. Ping repo admin if this option is not available. + +- [ ] **Promote release to fleets** + - [ ] Ask the PM lead to announce the release. + - [ ] Update infra config with any deprecated arguments or changed options. + - [ ] Update waku.sandbox with [this deployment job](https://ci.infra.status.im/job/nim-waku/job/deploy-waku-sandbox/). + +### Links + +- [Release process](https://github.com/waku-org/nwaku/blob/master/docs/contributors/release-process.md) +- [Release notes](https://github.com/waku-org/nwaku/blob/master/CHANGELOG.md) +- [Fleet ownership](https://www.notion.so/Fleet-Ownership-7532aad8896d46599abac3c274189741?pvs=4#d2d2f0fe4b3c429fbd860a1d64f89a64) +- [Infra-nim-waku](https://github.com/status-im/infra-nim-waku) +- [Jenkins](https://ci.infra.status.im/job/nim-waku/) +- [Fleets](https://fleets.waku.org/) +- [Harbor](https://harbor.status.im/harbor/projects/9/repositories/nwaku/artifacts-tab) diff --git a/.github/ISSUE_TEMPLATE/prepare_full_release.md b/.github/ISSUE_TEMPLATE/prepare_full_release.md new file mode 100644 index 000000000..18c668d16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/prepare_full_release.md @@ -0,0 +1,76 @@ +--- +name: Prepare Full Release +about: Execute tasks for the creation and publishing of a new full release +title: 'Prepare full release 0.0.0' +labels: full-release +assignees: '' + +--- + + + +### Items to complete + +All items below are to be completed by the owner of the given release. + +- [ ] Create release branch with major and minor only ( e.g. release/v0.X ) if it doesn't exist. +- [ ] Assign release candidate tag to the release branch HEAD (e.g. `v0.X.0-rc.0`, `v0.X.0-rc.1`, ... `v0.X.0-rc.N`). +- [ ] Generate and edit release notes in CHANGELOG.md. + +- [ ] **Validation of release candidate** + + - [ ] **Automated testing** + - [ ] Ensure all the unit tests (specifically js-waku tests) are green against the release candidate. + - [ ] Ask Vac-QA and Vac-DST to perform the available tests against the release candidate. + - [ ] Vac-DST (an additional report is needed; see [this](https://www.notion.so/DST-Reports-1228f96fb65c80729cd1d98a7496fe6f)) + + - [ ] **Waku fleet testing** + - [ ] Deploy the release candidate to `waku.test` and `waku.sandbox` fleets. + - Start the [deployment job](https://ci.infra.status.im/job/nim-waku/) for both fleets and wait for it to finish (Jenkins access required; ask the infra team if you don't have it). + - After completion, disable [deployment job](https://ci.infra.status.im/job/nim-waku/) so that its version is not updated on every merge to `master`. + - Verify the deployed version at https://fleets.waku.org/. + - Confirm the container image exists on [Harbor](https://harbor.status.im/harbor/projects/9/repositories/nwaku/artifacts-tab). + - [ ] Search _Kibana_ logs from the previous month (since the last release was deployed) for possible crashes or errors in `waku.test` and `waku.sandbox`. + - Most relevant logs are `(fleet: "waku.test" AND message: "SIGSEGV")` OR `(fleet: "waku.sandbox" AND message: "SIGSEGV")`. + - [ ] Enable again the `waku.test` fleet to resume auto-deployment of the latest `master` commit. + +- [ ] **Status fleet testing** + - [ ] Deploy release candidate to `status.staging` + - [ ] Perform [sanity check](https://www.notion.so/How-to-test-Nwaku-on-Status-12c6e4b9bf06420ca868bd199129b425) and log results as comments in this issue. + - [ ] Connect 2 instances to `status.staging` fleet, one in relay mode, the other one in light client. + - 1:1 Chats with each other + - Send and receive messages in a community + - Close one instance, send messages with second instance, reopen first instance and confirm messages sent while offline are retrieved from store + - [ ] Perform checks based on _end user impact_ + - [ ] Inform other (Waku and Status) CCs to point their instances to `status.staging` for a few days. Ping Status colleagues on their Discord server or in the [Status community](https://status.app/c/G3kAAMSQtb05kog3aGbr3kiaxN4tF5xy4BAGEkkLwILk2z3GcoYlm5hSJXGn7J3laft-tnTwDWmYJ18dP_3bgX96dqr_8E3qKAvxDf3NrrCMUBp4R9EYkQez9XSM4486mXoC3mIln2zc-TNdvjdfL9eHVZ-mGgs=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9) (this is not a blocking point.) + - [ ] Ask Status-QA to perform sanity checks (as described above) and checks based on _end user impact_; specify the version being tested + - [ ] Ask Status-QA or infra to run the automated Status e2e tests against `status.staging` + - [ ] Get other CCs' sign-off: they should comment on this PR, e.g., "Used the app for a week, no problem." If problems are reported, resolve them and create a new RC. + - [ ] **Get Status-QA sign-off**, ensuring that the `status.test` update will not disturb ongoing activities. + +- [ ] **Proceed with release** + + - [ ] Assign a final release tag (`v0.X.0`) to the same commit that contains the validated release-candidate tag (e.g. `v0.X.0`). + - [ ] Update [nwaku-compose](https://github.com/waku-org/nwaku-compose) and [waku-simulator](https://github.com/waku-org/waku-simulator) according to the new release. + - [ ] Bump nwaku dependency in [waku-rust-bindings](https://github.com/waku-org/waku-rust-bindings) and make sure all examples and tests work. + - [ ] Bump nwaku dependency in [waku-go-bindings](https://github.com/waku-org/waku-go-bindings) and make sure all tests work. + - [ ] Create GitHub release (https://github.com/waku-org/nwaku/releases). + - [ ] Submit a PR to merge the release branch back to `master`. Make sure you use the option "Merge pull request (Create a merge commit)" to perform the merge. Ping repo admin if this option is not available. + +- [ ] **Promote release to fleets** + - [ ] Ask the PM lead to announce the release. + - [ ] Update infra config with any deprecated arguments or changed options. + +### Links + +- [Release process](https://github.com/waku-org/nwaku/blob/master/docs/contributors/release-process.md) +- [Release notes](https://github.com/waku-org/nwaku/blob/master/CHANGELOG.md) +- [Fleet ownership](https://www.notion.so/Fleet-Ownership-7532aad8896d46599abac3c274189741?pvs=4#d2d2f0fe4b3c429fbd860a1d64f89a64) +- [Infra-nim-waku](https://github.com/status-im/infra-nim-waku) +- [Jenkins](https://ci.infra.status.im/job/nim-waku/) +- [Fleets](https://fleets.waku.org/) +- [Harbor](https://harbor.status.im/harbor/projects/9/repositories/nwaku/artifacts-tab) diff --git a/.github/ISSUE_TEMPLATE/prepare_release.md b/.github/ISSUE_TEMPLATE/prepare_release.md deleted file mode 100644 index 9553d5685..000000000 --- a/.github/ISSUE_TEMPLATE/prepare_release.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: Prepare release -about: Execute tasks for the creation and publishing of a new release -title: 'Prepare release 0.0.0' -labels: release -assignees: '' - ---- - - - -### Items to complete - -All items below are to be completed by the owner of the given release. - -- [ ] Create release branch -- [ ] Assign release candidate tag to the release branch HEAD. e.g. v0.30.0-rc.0 -- [ ] Generate and edit releases notes in CHANGELOG.md -- [ ] Review possible update of [config-options](https://github.com/waku-org/docs.waku.org/blob/develop/docs/guides/nwaku/config-options.md) -- [ ] _End user impact_: Summarize impact of changes on Status end users (can be a comment in this issue). -- [ ] **Validate release candidate** - - [ ] Bump nwaku dependency in [waku-rust-bindings](https://github.com/waku-org/waku-rust-bindings) and make sure all examples and tests work - -- [ ] Automated testing - - [ ] Ensures js-waku tests are green against release candidate - - [ ] Ask Vac-QA and Vac-DST to perform available tests against release candidate - - [ ] Vac-QA - - [ ] Vac-DST (we need additional report. see [this](https://www.notion.so/DST-Reports-1228f96fb65c80729cd1d98a7496fe6f)) - - - [ ] **On Waku fleets** - - [ ] Lock `waku.test` fleet to release candidate version - - [ ] Continuously stress `waku.test` fleet for a week (e.g. from `wakudev`) - - [ ] Search _Kibana_ logs from the previous month (since last release was deployed), for possible crashes or errors in `waku.test` and `waku.sandbox`. - - Most relevant logs are `(fleet: "waku.test" OR fleet: "waku.sandbox") AND message: "SIGSEGV"` - - [ ] Run release candidate with `waku-simulator`, ensure that nodes connected to each other - - [ ] Unlock `waku.test` to resume auto-deployment of latest `master` commit - - - [ ] **On Status fleet** - - [ ] Deploy release candidate to `status.staging` - - [ ] Perform [sanity check](https://www.notion.so/How-to-test-Nwaku-on-Status-12c6e4b9bf06420ca868bd199129b425) and log results as comments in this issue. - - [ ] Connect 2 instances to `status.staging` fleet, one in relay mode, the other one in light client. - - [ ] 1:1 Chats with each other - - [ ] Send and receive messages in a community - - [ ] Close one instance, send messages with second instance, reopen first instance and confirm messages sent while offline are retrieved from store - - [ ] Perform checks based _end user impact_ - - [ ] Inform other (Waku and Status) CCs to point their instance to `status.staging` for a few days. Ping Status colleagues from their Discord server or [Status community](https://status.app/c/G3kAAMSQtb05kog3aGbr3kiaxN4tF5xy4BAGEkkLwILk2z3GcoYlm5hSJXGn7J3laft-tnTwDWmYJ18dP_3bgX96dqr_8E3qKAvxDf3NrrCMUBp4R9EYkQez9XSM4486mXoC3mIln2zc-TNdvjdfL9eHVZ-mGgs=#zQ3shZeEJqTC1xhGUjxuS4rtHSrhJ8vUYp64v6qWkLpvdy9L9) (not blocking point.) - - [ ] Ask Status-QA to perform sanity checks (as described above) + checks based on _end user impact_; do specify the version being tested - - [ ] Ask Status-QA or infra to run the automated Status e2e tests against `status.staging` - - [ ] Get other CCs sign-off: they comment on this PR "used app for a week, no problem", or problem reported, resolved and new RC - - [ ] **Get Status-QA sign-off**. Ensuring that `status.test` update will not disturb ongoing activities. - -- [ ] **Proceed with release** - - - [ ] Assign a release tag to the same commit that contains the validated release-candidate tag - - [ ] Create GitHub release - - [ ] Deploy the release to DockerHub - - [ ] Announce the release - -- [ ] **Promote release to fleets**. - - [ ] Update infra config with any deprecated arguments or changed options - - [ ] [Deploy final release to `waku.sandbox` fleet](https://ci.infra.status.im/job/nim-waku/job/deploy-waku-sandbox) - - [ ] [Deploy final release to `status.staging` fleet](https://ci.infra.status.im/job/nim-waku/job/deploy-shards-staging/) - - [ ] [Deploy final release to `status.prod` fleet](https://ci.infra.status.im/job/nim-waku/job/deploy-shards-test/) - -- [ ] **Post release** - - [ ] Submit a PR from the release branch to master. Important to commit the PR with "create a merge commit" option. - - [ ] Update waku-org/nwaku-compose with the new release version. - - [ ] Update version in js-waku repo. [update only this](https://github.com/waku-org/js-waku/blob/7c0ce7b2eca31cab837da0251e1e4255151be2f7/.github/workflows/ci.yml#L135) by submitting a PR. diff --git a/docs/contributors/release-process.md b/docs/contributors/release-process.md index c0fb12d1c..bde63aa6f 100644 --- a/docs/contributors/release-process.md +++ b/docs/contributors/release-process.md @@ -6,44 +6,52 @@ For more context, see https://trunkbaseddevelopment.com/branch-for-release/ ## How to do releases -### Before release +### Prerequisites + +- All issues under the corresponding release [milestone](https://github.com/waku-org/nwaku/milestones) have been closed or, after consultation, deferred to the next release. +- All submodules are up to date. + > Updating submodules requires a PR (and very often several "fixes" to maintain compatibility with the changes in submodules). That PR process must be done and merged a couple of days before the release. -Ensure all items in this list are ticked: -- [ ] All issues under the corresponding release [milestone](https://github.com/waku-org/nwaku/milestones) has been closed or, after consultation, deferred to a next release. -- [ ] All submodules are up to date. - > **IMPORTANT:** Updating submodules requires a PR (and very often several "fixes" to maintain compatibility with the changes in submodules). That PR process must be done and merged a couple of days before the release. > In case the submodules update has a low effort and/or risk for the release, follow the ["Update submodules"](./git-submodules.md) instructions. - > If the effort or risk is too high, consider postponing the submodules upgrade for the subsequent release or delaying the current release until the submodules updates are included in the release candidate. -- [ ] The [js-waku CI tests](https://github.com/waku-org/js-waku/actions/workflows/ci.yml) pass against the release candidate (i.e. nwaku latest `master`). - > **NOTE:** This serves as a basic regression test against typical clients of nwaku. - > The specific job that needs to pass is named `node_with_nwaku_master`. -### Performing the release + > If the effort or risk is too high, consider postponing the submodules upgrade for the subsequent release or delaying the current release until the submodules updates are included in the release candidate. + +### Release types + +- **Full release**: follow the entire [Release process](#release-process--step-by-step). + +- **Beta release**: skip just `6a` and `6c` steps from [Release process](#release-process--step-by-step). + +- Choose the appropriate release process based on the release type: + - [Full Release](../../.github/ISSUE_TEMPLATE/prepare_full_release.md) + - [Beta Release](../../.github/ISSUE_TEMPLATE/prepare_beta_release.md) + +### Release process ( step by step ) 1. Checkout a release branch from master ``` - git checkout -b release/v0.1.0 + git checkout -b release/v0.X.0 ``` -1. Update `CHANGELOG.md` and ensure it is up to date. Use the helper Make target to get PR based release-notes/changelog update. +2. Update `CHANGELOG.md` and ensure it is up to date. Use the helper Make target to get PR based release-notes/changelog update. ``` make release-notes ``` -1. Create a release-candidate tag with the same name as release and `-rc.N` suffix a few days before the official release and push it +3. Create a release-candidate tag with the same name as release and `-rc.N` suffix a few days before the official release and push it ``` - git tag -as v0.1.0-rc.0 -m "Initial release." - git push origin v0.1.0-rc.0 + git tag -as v0.X.0-rc.0 -m "Initial release." + git push origin v0.X.0-rc.0 ``` - This will trigger a [workflow](../../.github/workflows/pre-release.yml) which will build RC artifacts and create and publish a Github release + This will trigger a [workflow](../../.github/workflows/pre-release.yml) which will build RC artifacts and create and publish a GitHub release -1. Open a PR from the release branch for others to review the included changes and the release-notes +4. Open a PR from the release branch for others to review the included changes and the release-notes -1. In case additional changes are needed, create a new RC tag +5. In case additional changes are needed, create a new RC tag Make sure the new tag is associated with CHANGELOG update. @@ -52,25 +60,57 @@ Ensure all items in this list are ticked: # Make changes, rebase and create new tag # Squash to one commit and make a nice commit message git rebase -i origin/master - git tag -as v0.1.0-rc.1 -m "Initial release." - git push origin v0.1.0-rc.1 + git tag -as v0.X.0-rc.1 -m "Initial release." + git push origin v0.X.0-rc.1 ``` -1. Validate the release. For the release validation process, please refer to the following [guide](https://www.notion.so/Release-Process-61234f335b904cd0943a5033ed8f42b4#47af557e7f9744c68fdbe5240bf93ca9) + Similarly use v0.X.0-rc.2, v0.X.0-rc.3 etc. for additional RC tags. -1. Once the release-candidate has been validated, create a final release tag and push it. -We also need to merge release branch back to master as a final step. +6. **Validation of release candidate** + + 6a. **Automated testing** + - Ensure all the unit tests (specifically js-waku tests) are green against the release candidate. + - Ask Vac-QA and Vac-DST to run their available tests against the release candidate; share all release candidates with both teams. + + > We need an additional report like [this](https://www.notion.so/DST-Reports-1228f96fb65c80729cd1d98a7496fe6f) specifically from the DST team. + + 6b. **Waku fleet testing** + - Start job on `waku.sandbox` and `waku.test` [Deployment job](https://ci.infra.status.im/job/nim-waku/), wait for completion of the job. If it fails, then debug it. + - After completion, disable [deployment job](https://ci.infra.status.im/job/nim-waku/) so that its version is not updated on every merge to `master`. + - Verify at https://fleets.waku.org/ that the fleet is locked to the release candidate version. + - Check if the image is created at [Harbor](https://harbor.status.im/harbor/projects/9/repositories/nwaku/artifacts-tab). + - Search _Kibana_ logs from the previous month (since the last release was deployed) for possible crashes or errors in `waku.test` and `waku.sandbox`. + - Most relevant logs are `(fleet: "waku.test" AND message: "SIGSEGV")` OR `(fleet: "waku.sandbox" AND message: "SIGSEGV")`. + - Enable the `waku.test` fleet again to resume auto-deployment of the latest `master` commit. + + 6c. **Status fleet testing** + - Deploy release candidate to `status.staging` + - Perform [sanity check](https://www.notion.so/How-to-test-Nwaku-on-Status-12c6e4b9bf06420ca868bd199129b425) and log results as comments in this issue. + - Connect 2 instances to `status.staging` fleet, one in relay mode, the other one in light client. + - 1:1 Chats with each other + - Send and receive messages in a community + - Close one instance, send messages with second instance, reopen first instance and confirm messages sent while offline are retrieved from store + - Perform checks based on _end-user impact_. + - Inform other (Waku and Status) CCs to point their instances to `status.staging` for a few days. Ping Status colleagues from their Discord server or [Status community](https://status.app) (not a blocking point). + - Ask Status-QA to perform sanity checks (as described above) and checks based on _end user impact_; specify the version being tested. + - Ask Status-QA or infra to run the automated Status e2e tests against `status.staging`. + - Get other CCs' sign-off: they should comment on this PR, e.g., "Used the app for a week, no problem." If problems are reported, resolve them and create a new RC. + - **Get Status-QA sign-off**, ensuring that the `status.test` update will not disturb ongoing activities. + +7. Once the release-candidate has been validated, create a final release tag and push it. +We also need to merge the release branch back into master as a final step. ``` - git checkout release/v0.1.0 - git tag -as v0.1.0 -m "Initial release." - git push origin v0.1.0 + git checkout release/v0.X.0 + git tag -as v0.X.0 -m "final release." (use v0.X.0-beta as the tag if you are creating a beta release) + git push origin v0.X.0 git switch master git pull - git merge release/v0.1.0 + git merge release/v0.X.0 ``` +8. Update `waku-rust-bindings`, `waku-simulator` and `nwaku-compose` to use the new release. -1. Create a [Github release](https://github.com/waku-org/nwaku/releases) from the release tag. +9. Create a [GitHub release](https://github.com/waku-org/nwaku/releases) from the release tag. * Add binaries produced by the ["Upload Release Asset"](https://github.com/waku-org/nwaku/actions/workflows/release-assets.yml) workflow. Where possible, test the binaries before uploading to the release. @@ -80,22 +120,10 @@ We also need to merge release branch back to master as a final step. 2. Deploy the release image to [Dockerhub](https://hub.docker.com/r/wakuorg/nwaku) by triggering [the manual Jenkins deployment job](https://ci.infra.status.im/job/nim-waku/job/docker-manual/). > Ensure the following build parameters are set: > - `MAKE_TARGET`: `wakunode2` - > - `IMAGE_TAG`: the release tag (e.g. `v0.16.0`) + > - `IMAGE_TAG`: the release tag (e.g. `v0.36.0`) > - `IMAGE_NAME`: `wakuorg/nwaku` > - `NIMFLAGS`: `--colors:off -d:disableMarchNative -d:chronicles_colors:none -d:postgres` - > - `GIT_REF` the release tag (e.g. `v0.16.0`) -3. Update the default nwaku image in [nwaku-compose](https://github.com/waku-org/nwaku-compose/blob/master/docker-compose.yml) -4. Deploy the release to appropriate fleets: - - Inform clients - > **NOTE:** known clients are currently using some version of js-waku, go-waku, nwaku or waku-rs. - > Clients are reachable via the corresponding channels on the Vac Discord server. - > It should be enough to inform clients on the `#nwaku` and `#announce` channels on Discord. - > Informal conversations with specific repo maintainers are often part of this process. - - Check if nwaku configuration parameters changed. If so [update fleet configuration](https://www.notion.so/Fleet-Ownership-7532aad8896d46599abac3c274189741?pvs=4#d2d2f0fe4b3c429fbd860a1d64f89a64) in [infra-nim-waku](https://github.com/status-im/infra-nim-waku) - - Deploy release to the `waku.sandbox` fleet from [Jenkins](https://ci.infra.status.im/job/nim-waku/job/deploy-waku-sandbox/). - - Ensure that nodes successfully start up and monitor health using [Grafana](https://grafana.infra.status.im/d/qrp_ZCTGz/nim-waku-v2?orgId=1) and [Kibana](https://kibana.infra.status.im/goto/a7728e70-eb26-11ec-81d1-210eb3022c76). - - If necessary, revert by deploying the previous release. Download logs and open a bug report issue. -5. Submit a PR to merge the release branch back to `master`. Make sure you use the option `Merge pull request (Create a merge commit)` to perform such merge. + > - `GIT_REF` the release tag (e.g. `v0.36.0`) ### Performing a patch release @@ -116,4 +144,14 @@ We also need to merge release branch back to master as a final step. 4. Once the release-candidate has been validated and changelog PR got merged, cherry-pick the changelog update from master to the release branch. Create a final release tag and push it. -5. Create a [Github release](https://github.com/waku-org/nwaku/releases) from the release tag and follow the same post-release process as usual. +5. Create a [GitHub release](https://github.com/waku-org/nwaku/releases) from the release tag and follow the same post-release process as usual. + +### Links + +- [Release process](https://github.com/waku-org/nwaku/blob/master/docs/contributors/release-process.md) +- [Release notes](https://github.com/waku-org/nwaku/blob/master/CHANGELOG.md) +- [Fleet ownership](https://www.notion.so/Fleet-Ownership-7532aad8896d46599abac3c274189741?pvs=4#d2d2f0fe4b3c429fbd860a1d64f89a64) +- [Infra-nim-waku](https://github.com/status-im/infra-nim-waku) +- [Jenkins](https://ci.infra.status.im/job/nim-waku/) +- [Fleets](https://fleets.waku.org/) +- [Harbor](https://harbor.status.im/harbor/projects/9/repositories/nwaku/artifacts-tab) \ No newline at end of file From ae74b9018a248ae4f641205d60e7122c024d47f6 Mon Sep 17 00:00:00 2001 From: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> Date: Tue, 2 Dec 2025 00:24:46 +0100 Subject: [PATCH 12/18] chore: Introduce EventBroker, RequestBroker and MultiRequestBroker (#3644) * Introduce EventBroker and RequestBroker as decoupling helpers that represent reactive (event-driven) and proactive (request/response) patterns without tight coupling between modules * Address copilot observation. error log if failed listener call exception, handling listener overuse - run out of IDs * Address review observations: no exception to leak, listeners must raise no exception, adding listener now reports error with Result. * Added MultiRequestBroker utility to collect results from many providers * Support an arbitrary number of arguments for RequestBroker's request/provider signature * MultiRequestBroker allows provider procs to throw exceptions, which will be handled during request processing. * MultiRequestBroker supports one zero arg signature and/or multi arg signature * test no exception leaks from RequestBroker and MultiRequestBroker * Embed MultiRequestBroker tests into common * EventBroker: removed all ...Broker typed public procs to simplify EventBroker interface, forger is renamed to dropListener * Make Request's broker type private * MultiRequestBroker: Use explicit returns in generated procs * Updated descriptions of EventBroker and RequestBroker, updated RequestBroker.setProvider, returns error if already set. * Better description for MultiRequestBroker and its usage * Add EventBroker support for ref objects, fix emit variant with event object ctor * Add RequestBroker support for ref objects * Add MultiRequestBroker support for ref objects * Mover brokers under waku/common --- tests/common/test_all.nim | 5 +- tests/common/test_event_broker.nim | 125 +++++ tests/common/test_multi_request_broker.nim | 234 ++++++++ tests/common/test_request_broker.nim | 198 +++++++ waku/common/broker/event_broker.nim | 308 +++++++++++ waku/common/broker/helper/broker_utils.nim | 43 ++ waku/common/broker/multi_request_broker.nim | 583 ++++++++++++++++++++ waku/common/broker/request_broker.nim | 438 +++++++++++++++ 8 files changed, 1933 insertions(+), 1 deletion(-) create mode 100644 tests/common/test_event_broker.nim create mode 100644 tests/common/test_multi_request_broker.nim create mode 100644 tests/common/test_request_broker.nim create mode 100644 waku/common/broker/event_broker.nim create mode 100644 waku/common/broker/helper/broker_utils.nim create mode 100644 waku/common/broker/multi_request_broker.nim create mode 100644 waku/common/broker/request_broker.nim diff --git a/tests/common/test_all.nim b/tests/common/test_all.nim index 5b4515093..7495c7c9e 100644 --- a/tests/common/test_all.nim +++ b/tests/common/test_all.nim @@ -9,4 +9,7 @@ import ./test_tokenbucket, ./test_requestratelimiter, ./test_ratelimit_setting, - ./test_timed_map + ./test_timed_map, + ./test_event_broker, + ./test_request_broker, + ./test_multi_request_broker diff --git a/tests/common/test_event_broker.nim b/tests/common/test_event_broker.nim new file mode 100644 index 000000000..cead1277f --- /dev/null +++ b/tests/common/test_event_broker.nim @@ -0,0 +1,125 @@ +import chronos +import std/sequtils +import testutils/unittests + +import waku/common/broker/event_broker + +EventBroker: + type SampleEvent = object + value*: int + label*: string + +EventBroker: + type BinaryEvent = object + flag*: bool + +EventBroker: + type RefEvent = ref object + payload*: seq[int] + +template waitForListeners() = + waitFor sleepAsync(1.milliseconds) + +suite "EventBroker": + test "delivers events to all listeners": + var seen: seq[(int, string)] = @[] + + discard SampleEvent.listen( + proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = + seen.add((evt.value, evt.label)) + ) + + discard SampleEvent.listen( + proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = + seen.add((evt.value * 2, evt.label & "!")) + ) + + let evt = SampleEvent(value: 5, label: "hi") + SampleEvent.emit(evt) + waitForListeners() + + check seen.len == 2 + check seen.anyIt(it == (5, "hi")) + check seen.anyIt(it == (10, "hi!")) + + SampleEvent.dropAllListeners() + + test "forget removes a single listener": + var counter = 0 + + let handleA = SampleEvent.listen( + proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = + inc counter + ) + + let handleB = SampleEvent.listen( + proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = + inc(counter, 2) + ) + + SampleEvent.dropListener(handleA.get()) + let eventVal = SampleEvent(value: 1, label: "one") + SampleEvent.emit(eventVal) + waitForListeners() + check counter == 2 + + SampleEvent.dropAllListeners() + + test "forgetAll clears every listener": + var triggered = false + + let handle1 = SampleEvent.listen( + proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = + triggered = true + ) + let handle2 = SampleEvent.listen( + proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = + discard + ) + + SampleEvent.dropAllListeners() + SampleEvent.emit(42, "noop") + SampleEvent.emit(label = "noop", value = 42) + waitForListeners() + check not triggered + + let freshHandle = SampleEvent.listen( + proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = + discard + ) + check freshHandle.get().id > 0'u64 + SampleEvent.dropListener(freshHandle.get()) + + test "broker helpers operate via typedesc": + var toggles: seq[bool] = @[] + + let handle = BinaryEvent.listen( + proc(evt: BinaryEvent): Future[void] {.async: (raises: []).} = + toggles.add(evt.flag) + ) + + BinaryEvent(flag: true).emit() + waitForListeners() + let binaryEvent = BinaryEvent(flag: false) + BinaryEvent.emit(binaryEvent) + waitForListeners() + + check toggles == @[true, false] + BinaryEvent.dropAllListeners() + + test "ref typed event": + var counter: int = 0 + + let handle = RefEvent.listen( + proc(evt: RefEvent): Future[void] {.async: (raises: []).} = + for n in evt.payload: + counter += n + ) + + RefEvent(payload: @[1, 2, 3]).emit() + waitForListeners() + RefEvent.emit(payload = @[4, 5, 6]) + waitForListeners() + + check counter == 21 # 1+2+3 + 4+5+6 + RefEvent.dropAllListeners() diff --git a/tests/common/test_multi_request_broker.nim b/tests/common/test_multi_request_broker.nim new file mode 100644 index 000000000..3bf10a54d --- /dev/null +++ b/tests/common/test_multi_request_broker.nim @@ -0,0 +1,234 @@ +{.used.} + +import testutils/unittests +import chronos +import std/sequtils +import std/strutils + +import waku/common/broker/multi_request_broker + +MultiRequestBroker: + type NoArgResponse = object + label*: string + + proc signatureFetch*(): Future[Result[NoArgResponse, string]] {.async.} + +MultiRequestBroker: + type ArgResponse = object + id*: string + + proc signatureFetch*( + suffix: string, numsuffix: int + ): Future[Result[ArgResponse, string]] {.async.} + +MultiRequestBroker: + type DualResponse = ref object + note*: string + suffix*: string + + proc signatureBase*(): Future[Result[DualResponse, string]] {.async.} + proc signatureWithInput*( + suffix: string + ): Future[Result[DualResponse, string]] {.async.} + +suite "MultiRequestBroker": + test "aggregates zero-argument providers": + discard NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + ok(NoArgResponse(label: "one")) + ) + + discard NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + discard catch: + await sleepAsync(1.milliseconds) + ok(NoArgResponse(label: "two")) + ) + + let responses = waitFor NoArgResponse.request() + check responses.get().len == 2 + check responses.get().anyIt(it.label == "one") + check responses.get().anyIt(it.label == "two") + + NoArgResponse.clearProviders() + + test "aggregates argument providers": + discard ArgResponse.setProvider( + proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = + ok(ArgResponse(id: suffix & "-a-" & $num)) + ) + + discard ArgResponse.setProvider( + proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = + ok(ArgResponse(id: suffix & "-b-" & $num)) + ) + + let keyed = waitFor ArgResponse.request("topic", 1) + check keyed.get().len == 2 + check keyed.get().anyIt(it.id == "topic-a-1") + check keyed.get().anyIt(it.id == "topic-b-1") + + ArgResponse.clearProviders() + + test "clearProviders resets both provider lists": + discard DualResponse.setProvider( + proc(): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "base", suffix: "")) + ) + + discard DualResponse.setProvider( + proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "base" & suffix, suffix: suffix)) + ) + + let noArgs = waitFor DualResponse.request() + check noArgs.get().len == 1 + + let param = waitFor DualResponse.request("-extra") + check param.get().len == 1 + check param.get()[0].suffix == "-extra" + + DualResponse.clearProviders() + + let emptyNoArgs = waitFor DualResponse.request() + check emptyNoArgs.get().len == 0 + + let emptyWithArgs = waitFor DualResponse.request("-extra") + check emptyWithArgs.get().len == 0 + + test "request returns empty seq when no providers registered": + let empty = waitFor NoArgResponse.request() + check empty.get().len == 0 + + test "failed providers will fail the request": + NoArgResponse.clearProviders() + discard NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + err("boom") + ) + + discard NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + ok(NoArgResponse(label: "survivor")) + ) + + let filtered = waitFor NoArgResponse.request() + check filtered.isErr() + + NoArgResponse.clearProviders() + + test "deduplicates identical zero-argument providers": + NoArgResponse.clearProviders() + var invocations = 0 + let sharedHandler = proc(): Future[Result[NoArgResponse, string]] {.async.} = + inc invocations + ok(NoArgResponse(label: "dup")) + + let first = NoArgResponse.setProvider(sharedHandler) + let second = NoArgResponse.setProvider(sharedHandler) + + check first.get().id == second.get().id + check first.get().kind == second.get().kind + + let dupResponses = waitFor NoArgResponse.request() + check dupResponses.get().len == 1 + check invocations == 1 + + NoArgResponse.clearProviders() + + test "removeProvider deletes registered handlers": + var removedCalled = false + var keptCalled = false + + let removable = NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + removedCalled = true + ok(NoArgResponse(label: "removed")) + ) + + discard NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + keptCalled = true + ok(NoArgResponse(label: "kept")) + ) + + NoArgResponse.removeProvider(removable.get()) + + let afterRemoval = (waitFor NoArgResponse.request()).valueOr: + assert false, "request failed" + @[] + check afterRemoval.len == 1 + check afterRemoval[0].label == "kept" + check not removedCalled + check keptCalled + + NoArgResponse.clearProviders() + + test "removeProvider works for argument signatures": + var invoked: seq[string] = @[] + + discard ArgResponse.setProvider( + proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = + invoked.add("first" & suffix) + ok(ArgResponse(id: suffix & "-one-" & $num)) + ) + + let handle = ArgResponse.setProvider( + proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = + invoked.add("second" & suffix) + ok(ArgResponse(id: suffix & "-two-" & $num)) + ) + + ArgResponse.removeProvider(handle.get()) + + let single = (waitFor ArgResponse.request("topic", 1)).valueOr: + assert false, "request failed" + @[] + check single.len == 1 + check single[0].id == "topic-one-1" + check invoked == @["firsttopic"] + + ArgResponse.clearProviders() + + test "catches exception from providers and report error": + let firstHandler = NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + raise newException(ValueError, "first handler raised") + ok(NoArgResponse(label: "any")) + ) + + discard NoArgResponse.setProvider( + proc(): Future[Result[NoArgResponse, string]] {.async.} = + ok(NoArgResponse(label: "just ok")) + ) + + let afterException = waitFor NoArgResponse.request() + check afterException.isErr() + check afterException.error().contains("first handler raised") + + NoArgResponse.clearProviders() + + test "ref providers returning nil fail request": + DualResponse.clearProviders() + + discard DualResponse.setProvider( + proc(): Future[Result[DualResponse, string]] {.async.} = + let nilResponse: DualResponse = nil + ok(nilResponse) + ) + + let zeroArg = waitFor DualResponse.request() + check zeroArg.isErr() + + DualResponse.clearProviders() + + discard DualResponse.setProvider( + proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = + let nilResponse: DualResponse = nil + ok(nilResponse) + ) + + let withInput = waitFor DualResponse.request("-extra") + check withInput.isErr() + + DualResponse.clearProviders() diff --git a/tests/common/test_request_broker.nim b/tests/common/test_request_broker.nim new file mode 100644 index 000000000..2ffd9cbf8 --- /dev/null +++ b/tests/common/test_request_broker.nim @@ -0,0 +1,198 @@ +{.used.} + +import testutils/unittests +import chronos +import std/strutils + +import waku/common/broker/request_broker + +RequestBroker: + type SimpleResponse = object + value*: string + + proc signatureFetch*(): Future[Result[SimpleResponse, string]] {.async.} + +RequestBroker: + type KeyedResponse = object + key*: string + payload*: string + + proc signatureFetchWithKey*( + key: string, subKey: int + ): Future[Result[KeyedResponse, string]] {.async.} + +RequestBroker: + type DualResponse = object + note*: string + count*: int + + proc signatureNoInput*(): Future[Result[DualResponse, string]] {.async.} + proc signatureWithInput*( + suffix: string + ): Future[Result[DualResponse, string]] {.async.} + +RequestBroker: + type ImplicitResponse = ref object + note*: string + +suite "RequestBroker macro": + test "serves zero-argument providers": + check SimpleResponse + .setProvider( + proc(): Future[Result[SimpleResponse, string]] {.async.} = + ok(SimpleResponse(value: "hi")) + ) + .isOk() + + let res = waitFor SimpleResponse.request() + check res.isOk() + check res.value.value == "hi" + + SimpleResponse.clearProvider() + + test "zero-argument request errors when unset": + let res = waitFor SimpleResponse.request() + check res.isErr + check res.error.contains("no zero-arg provider") + + test "serves input-based providers": + var seen: seq[string] = @[] + check KeyedResponse + .setProvider( + proc(key: string, subKey: int): Future[Result[KeyedResponse, string]] {.async.} = + seen.add(key) + ok(KeyedResponse(key: key, payload: key & "-payload+" & $subKey)) + ) + .isOk() + + let res = waitFor KeyedResponse.request("topic", 1) + check res.isOk() + check res.value.key == "topic" + check res.value.payload == "topic-payload+1" + check seen == @["topic"] + + KeyedResponse.clearProvider() + + test "catches provider exception": + check KeyedResponse + .setProvider( + proc(key: string, subKey: int): Future[Result[KeyedResponse, string]] {.async.} = + raise newException(ValueError, "simulated failure") + ok(KeyedResponse(key: key, payload: "")) + ) + .isOk() + + let res = waitFor KeyedResponse.request("neglected", 11) + check res.isErr() + check res.error.contains("simulated failure") + + KeyedResponse.clearProvider() + + test "input request errors when unset": + let res = waitFor KeyedResponse.request("foo", 2) + check res.isErr + check res.error.contains("input signature") + + test "supports both provider types simultaneously": + check DualResponse + .setProvider( + proc(): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "base", count: 1)) + ) + .isOk() + + check DualResponse + .setProvider( + proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "base" & suffix, count: suffix.len)) + ) + .isOk() + + let noInput = waitFor DualResponse.request() + check noInput.isOk + check noInput.value.note == "base" + + let withInput = waitFor DualResponse.request("-extra") + check withInput.isOk + check withInput.value.note == "base-extra" + check withInput.value.count == 6 + + DualResponse.clearProvider() + + test "clearProvider resets both entries": + check DualResponse + .setProvider( + proc(): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "temp", count: 0)) + ) + .isOk() + DualResponse.clearProvider() + + let res = waitFor DualResponse.request() + check res.isErr + + test "implicit zero-argument provider works by default": + check ImplicitResponse + .setProvider( + proc(): Future[Result[ImplicitResponse, string]] {.async.} = + ok(ImplicitResponse(note: "auto")) + ) + .isOk() + + let res = waitFor ImplicitResponse.request() + check res.isOk + + ImplicitResponse.clearProvider() + check res.value.note == "auto" + + test "implicit zero-argument request errors when unset": + let res = waitFor ImplicitResponse.request() + check res.isErr + check res.error.contains("no zero-arg provider") + + test "no provider override": + check DualResponse + .setProvider( + proc(): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "base", count: 1)) + ) + .isOk() + + check DualResponse + .setProvider( + proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "base" & suffix, count: suffix.len)) + ) + .isOk() + + let overrideProc = proc(): Future[Result[DualResponse, string]] {.async.} = + ok(DualResponse(note: "something else", count: 1)) + + check DualResponse.setProvider(overrideProc).isErr() + + let noInput = waitFor DualResponse.request() + check noInput.isOk + check noInput.value.note == "base" + + let stillResponse = waitFor DualResponse.request(" still works") + check stillResponse.isOk() + check stillResponse.value.note.contains("base still works") + + DualResponse.clearProvider() + + let noResponse = waitFor DualResponse.request() + check noResponse.isErr() + check noResponse.error.contains("no zero-arg provider") + + let noResponseArg = waitFor DualResponse.request("Should not work") + check noResponseArg.isErr() + check noResponseArg.error.contains("no provider") + + check DualResponse.setProvider(overrideProc).isOk() + + let nowSuccWithOverride = waitFor DualResponse.request() + check nowSuccWithOverride.isOk + check nowSuccWithOverride.value.note == "something else" + check nowSuccWithOverride.value.count == 1 + + DualResponse.clearProvider() diff --git a/waku/common/broker/event_broker.nim b/waku/common/broker/event_broker.nim new file mode 100644 index 000000000..05d7b50ab --- /dev/null +++ b/waku/common/broker/event_broker.nim @@ -0,0 +1,308 @@ +## EventBroker +## ------------------- +## EventBroker represents a reactive decoupling pattern, that +## allows event-driven development without +## need for direct dependencies in between emitters and listeners. +## Worth considering using it in a single or many emitters to many listeners scenario. +## +## Generates a standalone, type-safe event broker for the declared object type. +## The macro exports the value type itself plus a broker companion that manages +## listeners via thread-local storage. +## +## Usage: +## Declare your desired event type inside an `EventBroker` macro, add any number of fields.: +## ```nim +## EventBroker: +## type TypeName = object +## field1*: FieldType +## field2*: AnotherFieldType +## ``` +## +## After this, you can register async listeners anywhere in your code with +## `TypeName.listen(...)`, which returns a handle to the registered listener. +## Listeners are async procs or lambdas that take a single argument of the event type. +## Any number of listeners can be registered in different modules. +## +## Events can be emitted from anywhere with no direct dependency on the listeners by +## calling `TypeName.emit(...)` with an instance of the event type. +## This will asynchronously notify all registered listeners with the emitted event. +## +## Whenever you no longer need a listener (or your object instance that listen to the event goes out of scope), +## you can remove it from the broker with the handle returned by `listen`. +## This is done by calling `TypeName.dropListener(handle)`. +## Alternatively, you can remove all registered listeners through `TypeName.dropAllListeners()`. +## +## +## Example: +## ```nim +## EventBroker: +## type GreetingEvent = object +## text*: string +## +## let handle = GreetingEvent.listen( +## proc(evt: GreetingEvent): Future[void] {.async.} = +## echo evt.text +## ) +## GreetingEvent.emit(text= "hi") +## GreetingEvent.dropListener(handle) +## ``` + +import std/[macros, tables] +import chronos, chronicles, results +import ./helper/broker_utils + +export chronicles, results, chronos + +macro EventBroker*(body: untyped): untyped = + when defined(eventBrokerDebug): + echo body.treeRepr + var typeIdent: NimNode = nil + var objectDef: NimNode = nil + var fieldNames: seq[NimNode] = @[] + var fieldTypes: seq[NimNode] = @[] + var isRefObject = false + for stmt in body: + if stmt.kind == nnkTypeSection: + for def in stmt: + if def.kind != nnkTypeDef: + continue + let rhs = def[2] + var objectType: NimNode + case rhs.kind + of nnkObjectTy: + objectType = rhs + of nnkRefTy: + isRefObject = true + if rhs.len != 1 or rhs[0].kind != nnkObjectTy: + error("EventBroker ref object must wrap a concrete object definition", rhs) + objectType = rhs[0] + else: + continue + if not typeIdent.isNil(): + error("Only one object type may be declared inside EventBroker", def) + typeIdent = baseTypeIdent(def[0]) + let recList = objectType[2] + if recList.kind != nnkRecList: + error("EventBroker object must declare a standard field list", objectType) + var exportedRecList = newTree(nnkRecList) + for field in recList: + case field.kind + of nnkIdentDefs: + ensureFieldDef(field) + let fieldTypeNode = field[field.len - 2] + for i in 0 ..< field.len - 2: + let baseFieldIdent = baseTypeIdent(field[i]) + fieldNames.add(copyNimTree(baseFieldIdent)) + fieldTypes.add(copyNimTree(fieldTypeNode)) + var cloned = copyNimTree(field) + for i in 0 ..< cloned.len - 2: + cloned[i] = exportIdentNode(cloned[i]) + exportedRecList.add(cloned) + of nnkEmpty: + discard + else: + error( + "EventBroker object definition only supports simple field declarations", + field, + ) + let exportedObjectType = newTree( + nnkObjectTy, + copyNimTree(objectType[0]), + copyNimTree(objectType[1]), + exportedRecList, + ) + if isRefObject: + objectDef = newTree(nnkRefTy, exportedObjectType) + else: + objectDef = exportedObjectType + if typeIdent.isNil(): + error("EventBroker body must declare exactly one object type", body) + + let exportedTypeIdent = postfix(copyNimTree(typeIdent), "*") + let sanitized = sanitizeIdentName(typeIdent) + let typeNameLit = newLit($typeIdent) + let isRefObjectLit = newLit(isRefObject) + let handlerProcIdent = ident(sanitized & "ListenerProc") + let listenerHandleIdent = ident(sanitized & "Listener") + let brokerTypeIdent = ident(sanitized & "Broker") + let exportedHandlerProcIdent = postfix(copyNimTree(handlerProcIdent), "*") + let exportedListenerHandleIdent = postfix(copyNimTree(listenerHandleIdent), "*") + let exportedBrokerTypeIdent = postfix(copyNimTree(brokerTypeIdent), "*") + let accessProcIdent = ident("access" & sanitized & "Broker") + let globalVarIdent = ident("g" & sanitized & "Broker") + let listenImplIdent = ident("register" & sanitized & "Listener") + let dropListenerImplIdent = ident("drop" & sanitized & "Listener") + let dropAllListenersImplIdent = ident("dropAll" & sanitized & "Listeners") + let emitImplIdent = ident("emit" & sanitized & "Value") + let listenerTaskIdent = ident("notify" & sanitized & "Listener") + + result = newStmtList() + + result.add( + quote do: + type + `exportedTypeIdent` = `objectDef` + `exportedListenerHandleIdent` = object + id*: uint64 + + `exportedHandlerProcIdent` = + proc(event: `typeIdent`): Future[void] {.async: (raises: []), gcsafe.} + `exportedBrokerTypeIdent` = ref object + listeners: Table[uint64, `handlerProcIdent`] + nextId: uint64 + + ) + + result.add( + quote do: + var `globalVarIdent` {.threadvar.}: `brokerTypeIdent` + ) + + result.add( + quote do: + proc `accessProcIdent`(): `brokerTypeIdent` = + if `globalVarIdent`.isNil(): + new(`globalVarIdent`) + `globalVarIdent`.listeners = initTable[uint64, `handlerProcIdent`]() + `globalVarIdent` + + ) + + result.add( + quote do: + proc `listenImplIdent`( + handler: `handlerProcIdent` + ): Result[`listenerHandleIdent`, string] = + if handler.isNil(): + return err("Must provide a non-nil event handler") + var broker = `accessProcIdent`() + if broker.nextId == 0'u64: + broker.nextId = 1'u64 + if broker.nextId == high(uint64): + error "Cannot add more listeners: ID space exhausted", nextId = $broker.nextId + return err("Cannot add more listeners, listener ID space exhausted") + let newId = broker.nextId + inc broker.nextId + broker.listeners[newId] = handler + return ok(`listenerHandleIdent`(id: newId)) + + ) + + result.add( + quote do: + proc `dropListenerImplIdent`(handle: `listenerHandleIdent`) = + if handle.id == 0'u64: + return + var broker = `accessProcIdent`() + if broker.listeners.len == 0: + return + broker.listeners.del(handle.id) + + ) + + result.add( + quote do: + proc `dropAllListenersImplIdent`() = + var broker = `accessProcIdent`() + if broker.listeners.len > 0: + broker.listeners.clear() + + ) + + result.add( + quote do: + proc listen*( + _: typedesc[`typeIdent`], handler: `handlerProcIdent` + ): Result[`listenerHandleIdent`, string] = + return `listenImplIdent`(handler) + + ) + + result.add( + quote do: + proc dropListener*(_: typedesc[`typeIdent`], handle: `listenerHandleIdent`) = + `dropListenerImplIdent`(handle) + + proc dropAllListeners*(_: typedesc[`typeIdent`]) = + `dropAllListenersImplIdent`() + + ) + + result.add( + quote do: + proc `listenerTaskIdent`( + callback: `handlerProcIdent`, event: `typeIdent` + ) {.async: (raises: []), gcsafe.} = + if callback.isNil(): + return + try: + await callback(event) + except Exception: + error "Failed to execute event listener", error = getCurrentExceptionMsg() + + proc `emitImplIdent`( + event: `typeIdent` + ): Future[void] {.async: (raises: []), gcsafe.} = + when `isRefObjectLit`: + if event.isNil(): + error "Cannot emit uninitialized event object", eventType = `typeNameLit` + return + let broker = `accessProcIdent`() + if broker.listeners.len == 0: + # nothing to do as nobody is listening + return + var callbacks: seq[`handlerProcIdent`] = @[] + for cb in broker.listeners.values: + callbacks.add(cb) + for cb in callbacks: + asyncSpawn `listenerTaskIdent`(cb, event) + + proc emit*(event: `typeIdent`) = + asyncSpawn `emitImplIdent`(event) + + proc emit*(_: typedesc[`typeIdent`], event: `typeIdent`) = + asyncSpawn `emitImplIdent`(event) + + ) + + var emitCtorParams = newTree(nnkFormalParams, newEmptyNode()) + let typedescParamType = + newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)) + emitCtorParams.add( + newTree(nnkIdentDefs, ident("_"), typedescParamType, newEmptyNode()) + ) + for i in 0 ..< fieldNames.len: + emitCtorParams.add( + newTree( + nnkIdentDefs, + copyNimTree(fieldNames[i]), + copyNimTree(fieldTypes[i]), + newEmptyNode(), + ) + ) + + var emitCtorExpr = newTree(nnkObjConstr, copyNimTree(typeIdent)) + for i in 0 ..< fieldNames.len: + emitCtorExpr.add( + newTree(nnkExprColonExpr, copyNimTree(fieldNames[i]), copyNimTree(fieldNames[i])) + ) + + let emitCtorCall = newCall(copyNimTree(emitImplIdent), emitCtorExpr) + let emitCtorBody = quote: + asyncSpawn `emitCtorCall` + + let typedescEmitProc = newTree( + nnkProcDef, + postfix(ident("emit"), "*"), + newEmptyNode(), + newEmptyNode(), + emitCtorParams, + newEmptyNode(), + newEmptyNode(), + emitCtorBody, + ) + + result.add(typedescEmitProc) + + when defined(eventBrokerDebug): + echo result.repr diff --git a/waku/common/broker/helper/broker_utils.nim b/waku/common/broker/helper/broker_utils.nim new file mode 100644 index 000000000..ea9f85750 --- /dev/null +++ b/waku/common/broker/helper/broker_utils.nim @@ -0,0 +1,43 @@ +import std/macros + +proc sanitizeIdentName*(node: NimNode): string = + var raw = $node + var sanitizedName = newStringOfCap(raw.len) + for ch in raw: + case ch + of 'A' .. 'Z', 'a' .. 'z', '0' .. '9', '_': + sanitizedName.add(ch) + else: + sanitizedName.add('_') + sanitizedName + +proc ensureFieldDef*(node: NimNode) = + if node.kind != nnkIdentDefs or node.len < 3: + error("Expected field definition of the form `name: Type`", node) + let typeSlot = node.len - 2 + if node[typeSlot].kind == nnkEmpty: + error("Field `" & $node[0] & "` must declare a type", node) + +proc exportIdentNode*(node: NimNode): NimNode = + case node.kind + of nnkIdent: + postfix(copyNimTree(node), "*") + of nnkPostfix: + node + else: + error("Unsupported identifier form in field definition", node) + +proc baseTypeIdent*(defName: NimNode): NimNode = + case defName.kind + of nnkIdent: + defName + of nnkAccQuoted: + if defName.len != 1: + error("Unsupported quoted identifier", defName) + defName[0] + of nnkPostfix: + baseTypeIdent(defName[1]) + of nnkPragmaExpr: + baseTypeIdent(defName[0]) + else: + error("Unsupported type name in broker definition", defName) diff --git a/waku/common/broker/multi_request_broker.nim b/waku/common/broker/multi_request_broker.nim new file mode 100644 index 000000000..7f4161f5a --- /dev/null +++ b/waku/common/broker/multi_request_broker.nim @@ -0,0 +1,583 @@ +## MultiRequestBroker +## -------------------- +## MultiRequestBroker represents a proactive decoupling pattern, that +## allows defining request-response style interactions between modules without +## need for direct dependencies in between. +## Worth considering using it for use cases where you need to collect data from multiple providers. +## +## Provides a declarative way to define an immutable value type together with a +## thread-local broker that can register multiple asynchronous providers, dispatch +## typed requests, and clear handlers. Unlike `RequestBroker`, +## every call to `request` fan-outs to every registered provider and returns with +## collected responses. +## Request succeeds if all providers succeed, otherwise fails with an error. +## +## Usage: +## +## Declare collectable request data type inside a `MultiRequestBroker` macro, add any number of fields: +## ```nim +## MultiRequestBroker: +## type TypeName = object +## field1*: Type1 +## field2*: Type2 +## +## ## Define the request and provider signature, that is enforced at compile time. +## proc signature*(): Future[Result[TypeName, string]] {.async: (raises: []).} +## +## ## Also possible to define signature with arbitrary input arguments. +## proc signature*(arg1: ArgType, arg2: AnotherArgType): Future[Result[TypeName, string]] {.async: (raises: []).} +## +## ``` +## +## You regiser request processor (proveder) at any place of the code without the need to know of who ever may request. +## Respectively to the defined signatures register provider functions with `TypeName.setProvider(...)`. +## Providers are async procs or lambdas that return with a Future[Result[seq[TypeName], string]]. +## Notice MultiRequestBroker's `setProvider` return with a handler that can be used to remove the provider later (or error). + +## Requests can be made from anywhere with no direct dependency on the provider(s) by +## calling `TypeName.request()` - with arguments respecting the signature(s). +## This will asynchronously call the registered provider and return the collected data, in form of `Future[Result[seq[TypeName], string]]`. +## +## Whenever you don't want to process requests anymore (or your object instance that provides the request goes out of scope), +## you can remove it from the broker with `TypeName.removeProvider(handle)`. +## Alternatively, you can remove all registered providers through `TypeName.clearProviders()`. +## +## Example: +## ```nim +## MultiRequestBroker: +## type Greeting = object +## text*: string +## +## ## Define the request and provider signature, that is enforced at compile time. +## proc signature*(): Future[Result[Greeting, string]] {.async: (raises: []).} +## +## ## Also possible to define signature with arbitrary input arguments. +## proc signature*(lang: string): Future[Result[Greeting, string]] {.async: (raises: []).} +## +## ... +## let handle = Greeting.setProvider( +## proc(): Future[Result[Greeting, string]] {.async: (raises: []).} = +## ok(Greeting(text: "hello")) +## ) +## +## let anotherHandle = Greeting.setProvider( +## proc(): Future[Result[Greeting, string]] {.async: (raises: []).} = +## ok(Greeting(text: "szia")) +## ) +## +## let responses = (await Greeting.request()).valueOr(@[Greeting(text: "default")]) +## +## echo responses.len +## Greeting.clearProviders() +## ``` +## If no `signature` proc is declared, a zero-argument form is generated +## automatically, so the caller only needs to provide the type definition. + +import std/[macros, strutils, tables, sugar] +import chronos +import results +import ./helper/broker_utils + +export results, chronos + +proc isReturnTypeValid(returnType, typeIdent: NimNode): bool = + ## Accept Future[Result[TypeIdent, string]] as the contract. + if returnType.kind != nnkBracketExpr or returnType.len != 2: + return false + if returnType[0].kind != nnkIdent or not returnType[0].eqIdent("Future"): + return false + let inner = returnType[1] + if inner.kind != nnkBracketExpr or inner.len != 3: + return false + if inner[0].kind != nnkIdent or not inner[0].eqIdent("Result"): + return false + if inner[1].kind != nnkIdent or not inner[1].eqIdent($typeIdent): + return false + inner[2].kind == nnkIdent and inner[2].eqIdent("string") + +proc cloneParams(params: seq[NimNode]): seq[NimNode] = + ## Deep copy parameter definitions so they can be reused in generated nodes. + result = @[] + for param in params: + result.add(copyNimTree(param)) + +proc collectParamNames(params: seq[NimNode]): seq[NimNode] = + ## Extract identifiers declared in parameter definitions. + result = @[] + for param in params: + assert param.kind == nnkIdentDefs + for i in 0 ..< param.len - 2: + let nameNode = param[i] + if nameNode.kind == nnkEmpty: + continue + result.add(ident($nameNode)) + +proc makeProcType(returnType: NimNode, params: seq[NimNode]): NimNode = + var formal = newTree(nnkFormalParams) + formal.add(returnType) + for param in params: + formal.add(param) + + let pragmas = quote: + {.async.} + + newTree(nnkProcTy, formal, pragmas) + +macro MultiRequestBroker*(body: untyped): untyped = + when defined(requestBrokerDebug): + echo body.treeRepr + var typeIdent: NimNode = nil + var objectDef: NimNode = nil + var isRefObject = false + for stmt in body: + if stmt.kind == nnkTypeSection: + for def in stmt: + if def.kind != nnkTypeDef: + continue + let rhs = def[2] + var objectType: NimNode + case rhs.kind + of nnkObjectTy: + objectType = rhs + of nnkRefTy: + isRefObject = true + if rhs.len != 1 or rhs[0].kind != nnkObjectTy: + error( + "MultiRequestBroker ref object must wrap a concrete object definition", + rhs, + ) + objectType = rhs[0] + else: + continue + if not typeIdent.isNil(): + error("Only one object type may be declared inside MultiRequestBroker", def) + typeIdent = baseTypeIdent(def[0]) + let recList = objectType[2] + if recList.kind != nnkRecList: + error( + "MultiRequestBroker object must declare a standard field list", objectType + ) + var exportedRecList = newTree(nnkRecList) + for field in recList: + case field.kind + of nnkIdentDefs: + ensureFieldDef(field) + var cloned = copyNimTree(field) + for i in 0 ..< cloned.len - 2: + cloned[i] = exportIdentNode(cloned[i]) + exportedRecList.add(cloned) + of nnkEmpty: + discard + else: + error( + "MultiRequestBroker object definition only supports simple field declarations", + field, + ) + let exportedObjectType = newTree( + nnkObjectTy, + copyNimTree(objectType[0]), + copyNimTree(objectType[1]), + exportedRecList, + ) + if isRefObject: + objectDef = newTree(nnkRefTy, exportedObjectType) + else: + objectDef = exportedObjectType + if typeIdent.isNil(): + error("MultiRequestBroker body must declare exactly one object type", body) + + when defined(requestBrokerDebug): + echo "MultiRequestBroker generating type: ", $typeIdent + + let exportedTypeIdent = postfix(copyNimTree(typeIdent), "*") + let sanitized = sanitizeIdentName(typeIdent) + let typeNameLit = newLit($typeIdent) + let isRefObjectLit = newLit(isRefObject) + let tableSym = bindSym"Table" + let initTableSym = bindSym"initTable" + let uint64Ident = ident("uint64") + let providerKindIdent = ident(sanitized & "ProviderKind") + let providerHandleIdent = ident(sanitized & "ProviderHandle") + let exportedProviderHandleIdent = postfix(copyNimTree(providerHandleIdent), "*") + let zeroKindIdent = ident("pk" & sanitized & "NoArgs") + let argKindIdent = ident("pk" & sanitized & "WithArgs") + var zeroArgSig: NimNode = nil + var zeroArgProviderName: NimNode = nil + var zeroArgFieldName: NimNode = nil + var argSig: NimNode = nil + var argParams: seq[NimNode] = @[] + var argProviderName: NimNode = nil + var argFieldName: NimNode = nil + + for stmt in body: + case stmt.kind + of nnkProcDef: + let procName = stmt[0] + let procNameIdent = + case procName.kind + of nnkIdent: + procName + of nnkPostfix: + procName[1] + else: + procName + let procNameStr = $procNameIdent + if not procNameStr.startsWith("signature"): + error("Signature proc names must start with `signature`", procName) + let params = stmt.params + if params.len == 0: + error("Signature must declare a return type", stmt) + let returnType = params[0] + if not isReturnTypeValid(returnType, typeIdent): + error( + "Signature must return Future[Result[`" & $typeIdent & "`, string]]", stmt + ) + let paramCount = params.len - 1 + if paramCount == 0: + if zeroArgSig != nil: + error("Only one zero-argument signature is allowed", stmt) + zeroArgSig = stmt + zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") + zeroArgFieldName = ident("providerNoArgs") + elif paramCount >= 1: + if argSig != nil: + error("Only one argument-based signature is allowed", stmt) + argSig = stmt + argParams = @[] + for idx in 1 ..< params.len: + let paramDef = params[idx] + if paramDef.kind != nnkIdentDefs: + error( + "Signature parameter must be a standard identifier declaration", paramDef + ) + let paramTypeNode = paramDef[paramDef.len - 2] + if paramTypeNode.kind == nnkEmpty: + error("Signature parameter must declare a type", paramDef) + var hasName = false + for i in 0 ..< paramDef.len - 2: + if paramDef[i].kind != nnkEmpty: + hasName = true + if not hasName: + error("Signature parameter must declare a name", paramDef) + argParams.add(copyNimTree(paramDef)) + argProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderWithArgs") + argFieldName = ident("providerWithArgs") + of nnkTypeSection, nnkEmpty: + discard + else: + error("Unsupported statement inside MultiRequestBroker definition", stmt) + + if zeroArgSig.isNil() and argSig.isNil(): + zeroArgSig = newEmptyNode() + zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") + zeroArgFieldName = ident("providerNoArgs") + + var typeSection = newTree(nnkTypeSection) + typeSection.add(newTree(nnkTypeDef, exportedTypeIdent, newEmptyNode(), objectDef)) + + var kindEnum = newTree(nnkEnumTy, newEmptyNode()) + if not zeroArgSig.isNil(): + kindEnum.add(zeroKindIdent) + if not argSig.isNil(): + kindEnum.add(argKindIdent) + typeSection.add(newTree(nnkTypeDef, providerKindIdent, newEmptyNode(), kindEnum)) + + var handleRecList = newTree(nnkRecList) + handleRecList.add(newTree(nnkIdentDefs, ident("id"), uint64Ident, newEmptyNode())) + handleRecList.add( + newTree(nnkIdentDefs, ident("kind"), providerKindIdent, newEmptyNode()) + ) + typeSection.add( + newTree( + nnkTypeDef, + exportedProviderHandleIdent, + newEmptyNode(), + newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), handleRecList), + ) + ) + + let returnType = quote: + Future[Result[`typeIdent`, string]] + + if not zeroArgSig.isNil(): + let procType = makeProcType(returnType, @[]) + typeSection.add(newTree(nnkTypeDef, zeroArgProviderName, newEmptyNode(), procType)) + if not argSig.isNil(): + let procType = makeProcType(returnType, cloneParams(argParams)) + typeSection.add(newTree(nnkTypeDef, argProviderName, newEmptyNode(), procType)) + + var brokerRecList = newTree(nnkRecList) + if not zeroArgSig.isNil(): + brokerRecList.add( + newTree( + nnkIdentDefs, + zeroArgFieldName, + newTree(nnkBracketExpr, tableSym, uint64Ident, zeroArgProviderName), + newEmptyNode(), + ) + ) + if not argSig.isNil(): + brokerRecList.add( + newTree( + nnkIdentDefs, + argFieldName, + newTree(nnkBracketExpr, tableSym, uint64Ident, argProviderName), + newEmptyNode(), + ) + ) + brokerRecList.add(newTree(nnkIdentDefs, ident("nextId"), uint64Ident, newEmptyNode())) + let brokerTypeIdent = ident(sanitizeIdentName(typeIdent) & "Broker") + let brokerTypeDef = newTree( + nnkTypeDef, + brokerTypeIdent, + newEmptyNode(), + newTree( + nnkRefTy, newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), brokerRecList) + ), + ) + typeSection.add(brokerTypeDef) + result = newStmtList() + result.add(typeSection) + + let globalVarIdent = ident("g" & sanitizeIdentName(typeIdent) & "Broker") + let accessProcIdent = ident("access" & sanitizeIdentName(typeIdent) & "Broker") + var initStatements = newStmtList() + if not zeroArgSig.isNil(): + initStatements.add( + quote do: + `globalVarIdent`.`zeroArgFieldName` = + `initTableSym`[`uint64Ident`, `zeroArgProviderName`]() + ) + if not argSig.isNil(): + initStatements.add( + quote do: + `globalVarIdent`.`argFieldName` = + `initTableSym`[`uint64Ident`, `argProviderName`]() + ) + result.add( + quote do: + var `globalVarIdent` {.threadvar.}: `brokerTypeIdent` + + proc `accessProcIdent`(): `brokerTypeIdent` = + if `globalVarIdent`.isNil(): + new(`globalVarIdent`) + `globalVarIdent`.nextId = 1'u64 + `initStatements` + return `globalVarIdent` + + ) + + var clearBody = newStmtList() + if not zeroArgSig.isNil(): + result.add( + quote do: + proc setProvider*( + _: typedesc[`typeIdent`], handler: `zeroArgProviderName` + ): Result[`providerHandleIdent`, string] = + if handler.isNil(): + return err("Provider handler must be provided") + let broker = `accessProcIdent`() + if broker.nextId == 0'u64: + broker.nextId = 1'u64 + for existingId, existing in broker.`zeroArgFieldName`.pairs: + if existing == handler: + return ok(`providerHandleIdent`(id: existingId, kind: `zeroKindIdent`)) + let newId = broker.nextId + inc broker.nextId + broker.`zeroArgFieldName`[newId] = handler + return ok(`providerHandleIdent`(id: newId, kind: `zeroKindIdent`)) + + ) + clearBody.add( + quote do: + let broker = `accessProcIdent`() + if not broker.isNil() and broker.`zeroArgFieldName`.len > 0: + broker.`zeroArgFieldName`.clear() + ) + result.add( + quote do: + proc request*( + _: typedesc[`typeIdent`] + ): Future[Result[seq[`typeIdent`], string]] {.async: (raises: []), gcsafe.} = + var aggregated: seq[`typeIdent`] = @[] + let providers = `accessProcIdent`().`zeroArgFieldName` + if providers.len == 0: + return ok(aggregated) + # var providersFut: seq[Future[Result[`typeIdent`, string]]] = collect: + var providersFut = collect(newSeq): + for provider in providers.values: + if provider.isNil(): + continue + provider() + + let catchable = catch: + await allFinished(providersFut) + + catchable.isOkOr: + return err("Some provider(s) failed:" & error.msg) + + for fut in catchable.get(): + if fut.failed(): + return err("Some provider(s) failed:" & fut.error.msg) + elif fut.finished(): + let providerResult = fut.value() + if providerResult.isOk: + let providerValue = providerResult.get() + when `isRefObjectLit`: + if providerValue.isNil(): + return err( + "MultiRequestBroker(" & `typeNameLit` & + "): provider returned nil result" + ) + aggregated.add(providerValue) + else: + return err("Some provider(s) failed:" & providerResult.error) + + return ok(aggregated) + + ) + if not argSig.isNil(): + result.add( + quote do: + proc setProvider*( + _: typedesc[`typeIdent`], handler: `argProviderName` + ): Result[`providerHandleIdent`, string] = + if handler.isNil(): + return err("Provider handler must be provided") + let broker = `accessProcIdent`() + if broker.nextId == 0'u64: + broker.nextId = 1'u64 + for existingId, existing in broker.`argFieldName`.pairs: + if existing == handler: + return ok(`providerHandleIdent`(id: existingId, kind: `argKindIdent`)) + let newId = broker.nextId + inc broker.nextId + broker.`argFieldName`[newId] = handler + return ok(`providerHandleIdent`(id: newId, kind: `argKindIdent`)) + + ) + clearBody.add( + quote do: + let broker = `accessProcIdent`() + if not broker.isNil() and broker.`argFieldName`.len > 0: + broker.`argFieldName`.clear() + ) + let requestParamDefs = cloneParams(argParams) + let argNameIdents = collectParamNames(requestParamDefs) + let providerSym = genSym(nskLet, "providerVal") + var providerCall = newCall(providerSym) + for argName in argNameIdents: + providerCall.add(argName) + var formalParams = newTree(nnkFormalParams) + formalParams.add( + quote do: + Future[Result[seq[`typeIdent`], string]] + ) + formalParams.add( + newTree( + nnkIdentDefs, + ident("_"), + newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)), + newEmptyNode(), + ) + ) + for paramDef in requestParamDefs: + formalParams.add(paramDef) + let requestPragmas = quote: + {.async: (raises: []), gcsafe.} + let requestBody = quote: + var aggregated: seq[`typeIdent`] = @[] + let providers = `accessProcIdent`().`argFieldName` + if providers.len == 0: + return ok(aggregated) + var providersFut = collect(newSeq): + for provider in providers.values: + if provider.isNil(): + continue + let `providerSym` = provider + `providerCall` + let catchable = catch: + await allFinished(providersFut) + catchable.isOkOr: + return err("Some provider(s) failed:" & error.msg) + for fut in catchable.get(): + if fut.failed(): + return err("Some provider(s) failed:" & fut.error.msg) + elif fut.finished(): + let providerResult = fut.value() + if providerResult.isOk: + let providerValue = providerResult.get() + when `isRefObjectLit`: + if providerValue.isNil(): + return err( + "MultiRequestBroker(" & `typeNameLit` & + "): provider returned nil result" + ) + aggregated.add(providerValue) + else: + return err("Some provider(s) failed:" & providerResult.error) + return ok(aggregated) + + result.add( + newTree( + nnkProcDef, + postfix(ident("request"), "*"), + newEmptyNode(), + newEmptyNode(), + formalParams, + requestPragmas, + newEmptyNode(), + requestBody, + ) + ) + + result.add( + quote do: + proc clearProviders*(_: typedesc[`typeIdent`]) = + `clearBody` + let broker = `accessProcIdent`() + if not broker.isNil(): + broker.nextId = 1'u64 + + ) + + let removeHandleSym = genSym(nskParam, "handle") + let removeBrokerSym = genSym(nskLet, "broker") + var removeBody = newStmtList() + removeBody.add( + quote do: + if `removeHandleSym`.id == 0'u64: + return + let `removeBrokerSym` = `accessProcIdent`() + if `removeBrokerSym`.isNil(): + return + ) + if not zeroArgSig.isNil(): + removeBody.add( + quote do: + if `removeHandleSym`.kind == `zeroKindIdent`: + `removeBrokerSym`.`zeroArgFieldName`.del(`removeHandleSym`.id) + return + ) + if not argSig.isNil(): + removeBody.add( + quote do: + if `removeHandleSym`.kind == `argKindIdent`: + `removeBrokerSym`.`argFieldName`.del(`removeHandleSym`.id) + return + ) + removeBody.add( + quote do: + discard + ) + result.add( + quote do: + proc removeProvider*( + _: typedesc[`typeIdent`], `removeHandleSym`: `providerHandleIdent` + ) = + `removeBody` + + ) + + when defined(requestBrokerDebug): + echo result.repr diff --git a/waku/common/broker/request_broker.nim b/waku/common/broker/request_broker.nim new file mode 100644 index 000000000..a8a6651d7 --- /dev/null +++ b/waku/common/broker/request_broker.nim @@ -0,0 +1,438 @@ +## RequestBroker +## -------------------- +## RequestBroker represents a proactive decoupling pattern, that +## allows defining request-response style interactions between modules without +## need for direct dependencies in between. +## Worth considering using it in a single provider, many requester scenario. +## +## Provides a declarative way to define an immutable value type together with a +## thread-local broker that can register an asynchronous provider, dispatch typed +## requests and clear provider. +## +## Usage: +## Declare your desired request type inside a `RequestBroker` macro, add any number of fields. +## Define the provider signature, that is enforced at compile time. +## +## ```nim +## RequestBroker: +## type TypeName = object +## field1*: FieldType +## field2*: AnotherFieldType +## +## proc signature*(): Future[Result[TypeName, string]] +## ## Also possible to define signature with arbitrary input arguments. +## proc signature*(arg1: ArgType, arg2: AnotherArgType): Future[Result[TypeName, string]] +## +## ``` +## The 'TypeName' object defines the requestable data (but also can be seen as request for action with return value). +## The 'signature' proc defines the provider(s) signature, that is enforced at compile time. +## One signature can be with no arguments, another with any number of arguments - where the input arguments are +## not related to the request type - but alternative inputs for the request to be processed. +## +## After this, you can register a provider anywhere in your code with +## `TypeName.setProvider(...)`, which returns error if already having a provider. +## Providers are async procs or lambdas that take no arguments and return a Future[Result[TypeName, string]]. +## Only one provider can be registered at a time per signature type (zero arg and/or multi arg). +## +## Requests can be made from anywhere with no direct dependency on the provider by +## calling `TypeName.request()` - with arguments respecting the signature(s). +## This will asynchronously call the registered provider and return a Future[Result[TypeName, string]]. +## +## Whenever you no want to process requests (or your object instance that provides the request goes out of scope), +## you can remove it from the broker with `TypeName.clearProvider()`. +## +## +## Example: +## ```nim +## RequestBroker: +## type Greeting = object +## text*: string +## +## ## Define the request and provider signature, that is enforced at compile time. +## proc signature*(): Future[Result[Greeting, string]] +## +## ## Also possible to define signature with arbitrary input arguments. +## proc signature*(lang: string): Future[Result[Greeting, string]] +## +## ... +## Greeting.setProvider( +## proc(): Future[Result[Greeting, string]] {.async.} = +## ok(Greeting(text: "hello")) +## ) +## let res = await Greeting.request() +## ``` +## If no `signature` proc is declared, a zero-argument form is generated +## automatically, so the caller only needs to provide the type definition. + +import std/[macros, strutils] +import chronos +import results +import ./helper/broker_utils + +export results, chronos + +proc errorFuture[T](message: string): Future[Result[T, string]] {.inline.} = + ## Build a future that is already completed with an error result. + let fut = newFuture[Result[T, string]]("request_broker.errorFuture") + fut.complete(err(Result[T, string], message)) + fut + +proc isReturnTypeValid(returnType, typeIdent: NimNode): bool = + ## Accept Future[Result[TypeIdent, string]] as the contract. + if returnType.kind != nnkBracketExpr or returnType.len != 2: + return false + if returnType[0].kind != nnkIdent or not returnType[0].eqIdent("Future"): + return false + let inner = returnType[1] + if inner.kind != nnkBracketExpr or inner.len != 3: + return false + if inner[0].kind != nnkIdent or not inner[0].eqIdent("Result"): + return false + if inner[1].kind != nnkIdent or not inner[1].eqIdent($typeIdent): + return false + inner[2].kind == nnkIdent and inner[2].eqIdent("string") + +proc cloneParams(params: seq[NimNode]): seq[NimNode] = + ## Deep copy parameter definitions so they can be inserted in multiple places. + result = @[] + for param in params: + result.add(copyNimTree(param)) + +proc collectParamNames(params: seq[NimNode]): seq[NimNode] = + ## Extract all identifier symbols declared across IdentDefs nodes. + result = @[] + for param in params: + assert param.kind == nnkIdentDefs + for i in 0 ..< param.len - 2: + let nameNode = param[i] + if nameNode.kind == nnkEmpty: + continue + result.add(ident($nameNode)) + +proc makeProcType(returnType: NimNode, params: seq[NimNode]): NimNode = + var formal = newTree(nnkFormalParams) + formal.add(returnType) + for param in params: + formal.add(param) + let pragmas = newTree(nnkPragma, ident("async")) + newTree(nnkProcTy, formal, pragmas) + +macro RequestBroker*(body: untyped): untyped = + when defined(requestBrokerDebug): + echo body.treeRepr + var typeIdent: NimNode = nil + var objectDef: NimNode = nil + var isRefObject = false + for stmt in body: + if stmt.kind == nnkTypeSection: + for def in stmt: + if def.kind != nnkTypeDef: + continue + let rhs = def[2] + var objectType: NimNode + case rhs.kind + of nnkObjectTy: + objectType = rhs + of nnkRefTy: + isRefObject = true + if rhs.len != 1 or rhs[0].kind != nnkObjectTy: + error( + "RequestBroker ref object must wrap a concrete object definition", rhs + ) + objectType = rhs[0] + else: + continue + if not typeIdent.isNil(): + error("Only one object type may be declared inside RequestBroker", def) + typeIdent = baseTypeIdent(def[0]) + let recList = objectType[2] + if recList.kind != nnkRecList: + error("RequestBroker object must declare a standard field list", objectType) + var exportedRecList = newTree(nnkRecList) + for field in recList: + case field.kind + of nnkIdentDefs: + ensureFieldDef(field) + var cloned = copyNimTree(field) + for i in 0 ..< cloned.len - 2: + cloned[i] = exportIdentNode(cloned[i]) + exportedRecList.add(cloned) + of nnkEmpty: + discard + else: + error( + "RequestBroker object definition only supports simple field declarations", + field, + ) + let exportedObjectType = newTree( + nnkObjectTy, + copyNimTree(objectType[0]), + copyNimTree(objectType[1]), + exportedRecList, + ) + if isRefObject: + objectDef = newTree(nnkRefTy, exportedObjectType) + else: + objectDef = exportedObjectType + if typeIdent.isNil(): + error("RequestBroker body must declare exactly one object type", body) + + when defined(requestBrokerDebug): + echo "RequestBroker generating type: ", $typeIdent + + let exportedTypeIdent = postfix(copyNimTree(typeIdent), "*") + let typeDisplayName = sanitizeIdentName(typeIdent) + let typeNameLit = newLit(typeDisplayName) + let isRefObjectLit = newLit(isRefObject) + var zeroArgSig: NimNode = nil + var zeroArgProviderName: NimNode = nil + var zeroArgFieldName: NimNode = nil + var argSig: NimNode = nil + var argParams: seq[NimNode] = @[] + var argProviderName: NimNode = nil + var argFieldName: NimNode = nil + + for stmt in body: + case stmt.kind + of nnkProcDef: + let procName = stmt[0] + let procNameIdent = + case procName.kind + of nnkIdent: + procName + of nnkPostfix: + procName[1] + else: + procName + let procNameStr = $procNameIdent + if not procNameStr.startsWith("signature"): + error("Signature proc names must start with `signature`", procName) + let params = stmt.params + if params.len == 0: + error("Signature must declare a return type", stmt) + let returnType = params[0] + if not isReturnTypeValid(returnType, typeIdent): + error( + "Signature must return Future[Result[`" & $typeIdent & "`, string]]", stmt + ) + let paramCount = params.len - 1 + if paramCount == 0: + if zeroArgSig != nil: + error("Only one zero-argument signature is allowed", stmt) + zeroArgSig = stmt + zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") + zeroArgFieldName = ident("providerNoArgs") + elif paramCount >= 1: + if argSig != nil: + error("Only one argument-based signature is allowed", stmt) + argSig = stmt + argParams = @[] + for idx in 1 ..< params.len: + let paramDef = params[idx] + if paramDef.kind != nnkIdentDefs: + error( + "Signature parameter must be a standard identifier declaration", paramDef + ) + let paramTypeNode = paramDef[paramDef.len - 2] + if paramTypeNode.kind == nnkEmpty: + error("Signature parameter must declare a type", paramDef) + var hasName = false + for i in 0 ..< paramDef.len - 2: + if paramDef[i].kind != nnkEmpty: + hasName = true + if not hasName: + error("Signature parameter must declare a name", paramDef) + argParams.add(copyNimTree(paramDef)) + argProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderWithArgs") + argFieldName = ident("providerWithArgs") + of nnkTypeSection, nnkEmpty: + discard + else: + error("Unsupported statement inside RequestBroker definition", stmt) + + if zeroArgSig.isNil() and argSig.isNil(): + zeroArgSig = newEmptyNode() + zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") + zeroArgFieldName = ident("providerNoArgs") + + var typeSection = newTree(nnkTypeSection) + typeSection.add(newTree(nnkTypeDef, exportedTypeIdent, newEmptyNode(), objectDef)) + + let returnType = quote: + Future[Result[`typeIdent`, string]] + + if not zeroArgSig.isNil(): + let procType = makeProcType(returnType, @[]) + typeSection.add(newTree(nnkTypeDef, zeroArgProviderName, newEmptyNode(), procType)) + if not argSig.isNil(): + let procType = makeProcType(returnType, cloneParams(argParams)) + typeSection.add(newTree(nnkTypeDef, argProviderName, newEmptyNode(), procType)) + + var brokerRecList = newTree(nnkRecList) + if not zeroArgSig.isNil(): + brokerRecList.add( + newTree(nnkIdentDefs, zeroArgFieldName, zeroArgProviderName, newEmptyNode()) + ) + if not argSig.isNil(): + brokerRecList.add( + newTree(nnkIdentDefs, argFieldName, argProviderName, newEmptyNode()) + ) + let brokerTypeIdent = ident(sanitizeIdentName(typeIdent) & "Broker") + let brokerTypeDef = newTree( + nnkTypeDef, + brokerTypeIdent, + newEmptyNode(), + newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), brokerRecList), + ) + typeSection.add(brokerTypeDef) + result = newStmtList() + result.add(typeSection) + + let globalVarIdent = ident("g" & sanitizeIdentName(typeIdent) & "Broker") + let accessProcIdent = ident("access" & sanitizeIdentName(typeIdent) & "Broker") + result.add( + quote do: + var `globalVarIdent` {.threadvar.}: `brokerTypeIdent` + + proc `accessProcIdent`(): var `brokerTypeIdent` = + `globalVarIdent` + + ) + + var clearBody = newStmtList() + if not zeroArgSig.isNil(): + result.add( + quote do: + proc setProvider*( + _: typedesc[`typeIdent`], handler: `zeroArgProviderName` + ): Result[void, string] = + if not `accessProcIdent`().`zeroArgFieldName`.isNil(): + return err("Zero-arg provider already set") + `accessProcIdent`().`zeroArgFieldName` = handler + return ok() + + ) + clearBody.add( + quote do: + `accessProcIdent`().`zeroArgFieldName` = nil + ) + result.add( + quote do: + proc request*( + _: typedesc[`typeIdent`] + ): Future[Result[`typeIdent`, string]] {.async: (raises: []).} = + let provider = `accessProcIdent`().`zeroArgFieldName` + if provider.isNil(): + return err( + "RequestBroker(" & `typeNameLit` & "): no zero-arg provider registered" + ) + let catchedRes = catch: + await provider() + + if catchedRes.isErr(): + return err("Request failed:" & catchedRes.error.msg) + + let providerRes = catchedRes.get() + when `isRefObjectLit`: + if providerRes.isOk(): + let resultValue = providerRes.get() + if resultValue.isNil(): + return err( + "RequestBroker(" & `typeNameLit` & "): provider returned nil result" + ) + return providerRes + + ) + if not argSig.isNil(): + result.add( + quote do: + proc setProvider*( + _: typedesc[`typeIdent`], handler: `argProviderName` + ): Result[void, string] = + if not `accessProcIdent`().`argFieldName`.isNil(): + return err("Provider already set") + `accessProcIdent`().`argFieldName` = handler + return ok() + + ) + clearBody.add( + quote do: + `accessProcIdent`().`argFieldName` = nil + ) + let requestParamDefs = cloneParams(argParams) + let argNameIdents = collectParamNames(requestParamDefs) + let providerSym = genSym(nskLet, "provider") + var formalParams = newTree(nnkFormalParams) + formalParams.add( + quote do: + Future[Result[`typeIdent`, string]] + ) + formalParams.add( + newTree( + nnkIdentDefs, + ident("_"), + newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)), + newEmptyNode(), + ) + ) + for paramDef in requestParamDefs: + formalParams.add(paramDef) + + let requestPragmas = quote: + {.async: (raises: []), gcsafe.} + var providerCall = newCall(providerSym) + for argName in argNameIdents: + providerCall.add(argName) + var requestBody = newStmtList() + requestBody.add( + quote do: + let `providerSym` = `accessProcIdent`().`argFieldName` + ) + requestBody.add( + quote do: + if `providerSym`.isNil(): + return err( + "RequestBroker(" & `typeNameLit` & + "): no provider registered for input signature" + ) + ) + requestBody.add( + quote do: + let catchedRes = catch: + await `providerCall` + if catchedRes.isErr(): + return err("Request failed:" & catchedRes.error.msg) + + let providerRes = catchedRes.get() + when `isRefObjectLit`: + if providerRes.isOk(): + let resultValue = providerRes.get() + if resultValue.isNil(): + return err( + "RequestBroker(" & `typeNameLit` & "): provider returned nil result" + ) + return providerRes + ) + # requestBody.add(providerCall) + result.add( + newTree( + nnkProcDef, + postfix(ident("request"), "*"), + newEmptyNode(), + newEmptyNode(), + formalParams, + requestPragmas, + newEmptyNode(), + requestBody, + ) + ) + + result.add( + quote do: + proc clearProvider*(_: typedesc[`typeIdent`]) = + `clearBody` + + ) + + when defined(requestBrokerDebug): + echo result.repr From 54f4ad8fa2ed452df670e25213a7fa34e5cc5432 Mon Sep 17 00:00:00 2001 From: Fabiana Cecin Date: Tue, 2 Dec 2025 11:00:26 -0300 Subject: [PATCH 13/18] fix: fix .github waku-org/ --> logos-messaging/ (#3653) * fix: fix .github waku-org/ --> logos-messaging/ * bump CI tests timeout 45 --> 90 minutes * fix .gitmodules waku-org --> logos-messaging --- .github/ISSUE_TEMPLATE/prepare_beta_release.md | 14 +++++++------- .github/ISSUE_TEMPLATE/prepare_full_release.md | 16 ++++++++-------- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/pre-release.yml | 10 +++++----- .gitmodules | 2 +- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/prepare_beta_release.md b/.github/ISSUE_TEMPLATE/prepare_beta_release.md index 270f6a8e6..9afaefbd1 100644 --- a/.github/ISSUE_TEMPLATE/prepare_beta_release.md +++ b/.github/ISSUE_TEMPLATE/prepare_beta_release.md @@ -10,7 +10,7 @@ assignees: '' ### Items to complete @@ -34,10 +34,10 @@ All items below are to be completed by the owner of the given release. - [ ] **Proceed with release** - [ ] Assign a final release tag (`v0.X.0-beta`) to the same commit that contains the validated release-candidate tag (e.g. `v0.X.0-beta-rc.N`) and submit a PR from the release branch to `master`. - - [ ] Update [nwaku-compose](https://github.com/waku-org/nwaku-compose) and [waku-simulator](https://github.com/waku-org/waku-simulator) according to the new release. - - [ ] Bump nwaku dependency in [waku-rust-bindings](https://github.com/waku-org/waku-rust-bindings) and make sure all examples and tests work. - - [ ] Bump nwaku dependency in [waku-go-bindings](https://github.com/waku-org/waku-go-bindings) and make sure all tests work. - - [ ] Create GitHub release (https://github.com/waku-org/nwaku/releases). + - [ ] Update [nwaku-compose](https://github.com/logos-messaging/nwaku-compose) and [waku-simulator](https://github.com/logos-messaging/waku-simulator) according to the new release. + - [ ] Bump nwaku dependency in [waku-rust-bindings](https://github.com/logos-messaging/waku-rust-bindings) and make sure all examples and tests work. + - [ ] Bump nwaku dependency in [waku-go-bindings](https://github.com/logos-messaging/waku-go-bindings) and make sure all tests work. + - [ ] Create GitHub release (https://github.com/logos-messaging/nwaku/releases). - [ ] Submit a PR to merge the release branch back to `master`. Make sure you use the option "Merge pull request (Create a merge commit)" to perform the merge. Ping repo admin if this option is not available. - [ ] **Promote release to fleets** @@ -47,8 +47,8 @@ All items below are to be completed by the owner of the given release. ### Links -- [Release process](https://github.com/waku-org/nwaku/blob/master/docs/contributors/release-process.md) -- [Release notes](https://github.com/waku-org/nwaku/blob/master/CHANGELOG.md) +- [Release process](https://github.com/logos-messaging/nwaku/blob/master/docs/contributors/release-process.md) +- [Release notes](https://github.com/logos-messaging/nwaku/blob/master/CHANGELOG.md) - [Fleet ownership](https://www.notion.so/Fleet-Ownership-7532aad8896d46599abac3c274189741?pvs=4#d2d2f0fe4b3c429fbd860a1d64f89a64) - [Infra-nim-waku](https://github.com/status-im/infra-nim-waku) - [Jenkins](https://ci.infra.status.im/job/nim-waku/) diff --git a/.github/ISSUE_TEMPLATE/prepare_full_release.md b/.github/ISSUE_TEMPLATE/prepare_full_release.md index 18c668d16..314146f60 100644 --- a/.github/ISSUE_TEMPLATE/prepare_full_release.md +++ b/.github/ISSUE_TEMPLATE/prepare_full_release.md @@ -10,7 +10,7 @@ assignees: '' ### Items to complete @@ -54,11 +54,11 @@ All items below are to be completed by the owner of the given release. - [ ] **Proceed with release** - - [ ] Assign a final release tag (`v0.X.0`) to the same commit that contains the validated release-candidate tag (e.g. `v0.X.0`). - - [ ] Update [nwaku-compose](https://github.com/waku-org/nwaku-compose) and [waku-simulator](https://github.com/waku-org/waku-simulator) according to the new release. - - [ ] Bump nwaku dependency in [waku-rust-bindings](https://github.com/waku-org/waku-rust-bindings) and make sure all examples and tests work. - - [ ] Bump nwaku dependency in [waku-go-bindings](https://github.com/waku-org/waku-go-bindings) and make sure all tests work. - - [ ] Create GitHub release (https://github.com/waku-org/nwaku/releases). + - [ ] Assign a final release tag (`v0.X.0`) to the same commit that contains the validated release-candidate tag (e.g. `v0.X.0`). + - [ ] Update [nwaku-compose](https://github.com/logos-messaging/nwaku-compose) and [waku-simulator](https://github.com/logos-messaging/waku-simulator) according to the new release. + - [ ] Bump nwaku dependency in [waku-rust-bindings](https://github.com/logos-messaging/waku-rust-bindings) and make sure all examples and tests work. + - [ ] Bump nwaku dependency in [waku-go-bindings](https://github.com/logos-messaging/waku-go-bindings) and make sure all tests work. + - [ ] Create GitHub release (https://github.com/logos-messaging/nwaku/releases). - [ ] Submit a PR to merge the release branch back to `master`. Make sure you use the option "Merge pull request (Create a merge commit)" to perform the merge. Ping repo admin if this option is not available. - [ ] **Promote release to fleets** @@ -67,8 +67,8 @@ All items below are to be completed by the owner of the given release. ### Links -- [Release process](https://github.com/waku-org/nwaku/blob/master/docs/contributors/release-process.md) -- [Release notes](https://github.com/waku-org/nwaku/blob/master/CHANGELOG.md) +- [Release process](https://github.com/logos-messaging/nwaku/blob/master/docs/contributors/release-process.md) +- [Release notes](https://github.com/logos-messaging/nwaku/blob/master/CHANGELOG.md) - [Fleet ownership](https://www.notion.so/Fleet-Ownership-7532aad8896d46599abac3c274189741?pvs=4#d2d2f0fe4b3c429fbd860a1d64f89a64) - [Infra-nim-waku](https://github.com/status-im/infra-nim-waku) - [Jenkins](https://ci.infra.status.im/job/nim-waku/) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cf64b66a..12c1abd6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: - name: Build binaries run: make V=1 QUICK_AND_DIRTY_COMPILER=1 all tools - + build-windows: needs: changes if: ${{ needs.changes.outputs.v2 == 'true' || needs.changes.outputs.common == 'true' }} @@ -94,7 +94,7 @@ jobs: matrix: os: [ubuntu-22.04, macos-15] runs-on: ${{ matrix.os }} - timeout-minutes: 45 + timeout-minutes: 90 name: test-${{ matrix.os }} steps: @@ -121,7 +121,7 @@ jobs: sudo docker run --rm -d -e POSTGRES_PASSWORD=test123 -p 5432:5432 postgres:15.4-alpine3.18 postgres_enabled=1 fi - + export MAKEFLAGS="-j1" export NIMFLAGS="--colors:off -d:chronicles_colors:none" export USE_LIBBACKTRACE=0 @@ -132,12 +132,12 @@ jobs: build-docker-image: needs: changes if: ${{ needs.changes.outputs.v2 == 'true' || needs.changes.outputs.common == 'true' || needs.changes.outputs.docker == 'true' }} - uses: waku-org/nwaku/.github/workflows/container-image.yml@master + uses: logos-messaging/nwaku/.github/workflows/container-image.yml@master secrets: inherit nwaku-nwaku-interop-tests: needs: build-docker-image - uses: waku-org/waku-interop-tests/.github/workflows/nim_waku_PR.yml@SMOKE_TEST_0.0.1 + uses: logos-messaging/waku-interop-tests/.github/workflows/nim_waku_PR.yml@SMOKE_TEST_0.0.1 with: node_nwaku: ${{ needs.build-docker-image.outputs.image }} @@ -145,14 +145,14 @@ jobs: js-waku-node: needs: build-docker-image - uses: waku-org/js-waku/.github/workflows/test-node.yml@master + uses: logos-messaging/js-waku/.github/workflows/test-node.yml@master with: nim_wakunode_image: ${{ needs.build-docker-image.outputs.image }} test_type: node js-waku-node-optional: needs: build-docker-image - uses: waku-org/js-waku/.github/workflows/test-node.yml@master + uses: logos-messaging/js-waku/.github/workflows/test-node.yml@master with: nim_wakunode_image: ${{ needs.build-docker-image.outputs.image }} test_type: node-optional diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index fe108e616..380ec755f 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -47,7 +47,7 @@ jobs: - name: prep variables id: vars run: | - ARCH=${{matrix.arch}} + ARCH=${{matrix.arch}} echo "arch=${ARCH}" >> $GITHUB_OUTPUT @@ -91,14 +91,14 @@ jobs: build-docker-image: needs: tag-name - uses: waku-org/nwaku/.github/workflows/container-image.yml@master + uses: logos-messaging/nwaku/.github/workflows/container-image.yml@master with: image_tag: ${{ needs.tag-name.outputs.tag }} secrets: inherit js-waku-node: needs: build-docker-image - uses: waku-org/js-waku/.github/workflows/test-node.yml@master + uses: logos-messaging/js-waku/.github/workflows/test-node.yml@master with: nim_wakunode_image: ${{ needs.build-docker-image.outputs.image }} test_type: node @@ -106,7 +106,7 @@ jobs: js-waku-node-optional: needs: build-docker-image - uses: waku-org/js-waku/.github/workflows/test-node.yml@master + uses: logos-messaging/js-waku/.github/workflows/test-node.yml@master with: nim_wakunode_image: ${{ needs.build-docker-image.outputs.image }} test_type: node-optional @@ -150,7 +150,7 @@ jobs: -u $(id -u) \ docker.io/wakuorg/sv4git:latest \ release-notes ${RELEASE_NOTES_TAG} --previous $(git tag -l --sort -creatordate | grep -e "^v[0-9]*\.[0-9]*\.[0-9]*$") |\ - sed -E 's@#([0-9]+)@[#\1](https://github.com/waku-org/nwaku/issues/\1)@g' > release_notes.md + sed -E 's@#([0-9]+)@[#\1](https://github.com/logos-messaging/nwaku/issues/\1)@g' > release_notes.md sed -i "s/^## .*/Generated at $(date)/" release_notes.md diff --git a/.gitmodules b/.gitmodules index b7e52550a..93a3a006f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -181,6 +181,6 @@ branch = master [submodule "vendor/waku-rlnv2-contract"] path = vendor/waku-rlnv2-contract - url = https://github.com/waku-org/waku-rlnv2-contract.git + url = https://github.com/logos-messaging/waku-rlnv2-contract.git ignore = untracked branch = master From 8c30a8e1bb7469e6184d1ac6289676aec27b719d Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:55:34 +0100 Subject: [PATCH 14/18] Rest store api constraints default page size to 20 and max to 100 (#3602) Co-authored-by: Vishwanath Martur <64204611+vishwamartur@users.noreply.github.com> --- docs/api/rest-api.md | 3 +++ docs/operators/how-to/configure-rest-api.md | 3 ++- waku/rest_api/endpoint/store/client.nim | 2 +- waku/rest_api/endpoint/store/handlers.nim | 8 ++++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index eeb90abfb..cc8e51020 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -38,6 +38,9 @@ A particular OpenAPI spec can be easily imported into [Postman](https://www.post curl http://localhost:8645/debug/v1/info -s | jq ``` +### Store API + +The `page_size` flag in the Store API has a default value of 20 and a max value of 100. ### Node configuration Find details [here](https://github.com/waku-org/nwaku/tree/master/docs/operators/how-to/configure-rest-api.md) diff --git a/docs/operators/how-to/configure-rest-api.md b/docs/operators/how-to/configure-rest-api.md index 3fe070aab..7a58a798c 100644 --- a/docs/operators/how-to/configure-rest-api.md +++ b/docs/operators/how-to/configure-rest-api.md @@ -1,4 +1,3 @@ - # Configure a REST API node A subset of the node configuration can be used to modify the behaviour of the HTTP REST API. @@ -21,3 +20,5 @@ Example: ```shell wakunode2 --rest=true ``` + +The `page_size` flag in the Store API has a default value of 20 and a max value of 100. diff --git a/waku/rest_api/endpoint/store/client.nim b/waku/rest_api/endpoint/store/client.nim index 80939ee25..71ba7610d 100644 --- a/waku/rest_api/endpoint/store/client.nim +++ b/waku/rest_api/endpoint/store/client.nim @@ -57,7 +57,7 @@ proc getStoreMessagesV3*( # Optional cursor fields cursor: string = "", # base64-encoded hash ascending: string = "", - pageSize: string = "", + pageSize: string = "20", # default value is 20 ): RestResponse[StoreQueryResponseHex] {. rest, endpoint: "/store/v3/messages", meth: HttpMethod.MethodGet .} diff --git a/waku/rest_api/endpoint/store/handlers.nim b/waku/rest_api/endpoint/store/handlers.nim index 79724b9d7..7d37191fb 100644 --- a/waku/rest_api/endpoint/store/handlers.nim +++ b/waku/rest_api/endpoint/store/handlers.nim @@ -129,6 +129,14 @@ proc createStoreQuery( except CatchableError: return err("page size parsing error: " & getCurrentExceptionMsg()) + # Enforce default value of page_size to 20 + if parsedPagedSize.isNone(): + parsedPagedSize = some(20.uint64) + + # Enforce max value of page_size to 100 + if parsedPagedSize.get() > 100: + parsedPagedSize = some(100.uint64) + return ok( StoreQueryRequest( includeData: parsedIncludeData, From a8590a0a7dd53776bc2fef87149fdb084b58d317 Mon Sep 17 00:00:00 2001 From: Tanya S <120410716+stubbsta@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:26:18 +0200 Subject: [PATCH 15/18] chore: Add gasprice overflow check (#3636) * Check for gasPrice overflow * use trace for logging and update comments * Update log level for gas price logs --- .../group_manager/on_chain/group_manager.nim | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim index db68b2289..e8af61682 100644 --- a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim +++ b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim @@ -229,7 +229,18 @@ method register*( var gasPrice: int g.retryWrapper(gasPrice, "Failed to get gas price"): - int(await ethRpc.provider.eth_gasPrice()) * 2 + let fetchedGasPrice = uint64(await ethRpc.provider.eth_gasPrice()) + ## Multiply by 2 to speed up the transaction + ## Check for overflow when casting to int + if fetchedGasPrice > uint64(high(int) div 2): + warn "Gas price overflow detected, capping at maximum int value", + fetchedGasPrice = fetchedGasPrice, maxInt = high(int) + high(int) + else: + let calculatedGasPrice = int(fetchedGasPrice) * 2 + debug "Gas price calculated", + fetchedGasPrice = fetchedGasPrice, gasPrice = calculatedGasPrice + calculatedGasPrice let idCommitmentHex = identityCredential.idCommitment.inHex() info "identityCredential idCommitmentHex", idCommitment = idCommitmentHex let idCommitment = identityCredential.idCommitment.toUInt256() From 2cf4fe559a0a6a4511cc9da2b69c7935ebc7862f Mon Sep 17 00:00:00 2001 From: Tanya S <120410716+stubbsta@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:29:48 +0200 Subject: [PATCH 16/18] Chore: bump waku-rlnv2-contract-repo commit (#3651) * Bump commit for vendor wakurlnv2contract * Update RLN registration proc for contract updates * add option to runAnvil for state dump or load with optional contract deployment on setup * Code clean up * Upodate rln relay tests to use cached anvil state * Minor updates to utils and new test for anvil state dump * stopAnvil needs to wait for graceful shutdown * configure runAnvil to use load state in other tests * reduce ci timeout * Allow for RunAnvil load state file to be compressed * Fix linting * Change return type of sendMintCall to Futre[void] * Update naming of ci path for interop tests --- .github/workflows/ci.yml | 4 +- tests/node/test_wakunode_legacy_lightpush.nim | 4 +- tests/node/test_wakunode_lightpush.nim | 4 +- ...ployed-contracts-mint-and-approved.json.gz | Bin 0 -> 118346 bytes .../test_rln_contract_deployment.nim | 29 ++ .../test_rln_group_manager_onchain.nim | 4 +- tests/waku_rln_relay/test_waku_rln_relay.nim | 4 +- .../test_wakunode_rln_relay.nim | 4 +- tests/waku_rln_relay/utils_onchain.nim | 249 ++++++++++++++---- tests/wakunode_rest/test_rest_health.nim | 4 +- vendor/waku-rlnv2-contract | 2 +- .../group_manager/on_chain/group_manager.nim | 38 +-- 12 files changed, 259 insertions(+), 87 deletions(-) create mode 100644 tests/waku_rln_relay/anvil_state/state-deployed-contracts-mint-and-approved.json.gz create mode 100644 tests/waku_rln_relay/test_rln_contract_deployment.nim diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12c1abd6d..e3186a007 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: matrix: os: [ubuntu-22.04, macos-15] runs-on: ${{ matrix.os }} - timeout-minutes: 90 + timeout-minutes: 45 name: test-${{ matrix.os }} steps: @@ -137,7 +137,7 @@ jobs: nwaku-nwaku-interop-tests: needs: build-docker-image - uses: logos-messaging/waku-interop-tests/.github/workflows/nim_waku_PR.yml@SMOKE_TEST_0.0.1 + uses: logos-messaging/logos-messaging-interop-tests/.github/workflows/nim_waku_PR.yml@SMOKE_TEST_0.0.1 with: node_nwaku: ${{ needs.build-docker-image.outputs.image }} diff --git a/tests/node/test_wakunode_legacy_lightpush.nim b/tests/node/test_wakunode_legacy_lightpush.nim index a51ba60b9..80e623ce4 100644 --- a/tests/node/test_wakunode_legacy_lightpush.nim +++ b/tests/node/test_wakunode_legacy_lightpush.nim @@ -135,8 +135,8 @@ suite "RLN Proofs as a Lightpush Service": server = newTestWakuNode(serverKey, parseIpAddress("0.0.0.0"), Port(0)) client = newTestWakuNode(clientKey, parseIpAddress("0.0.0.0"), Port(0)) - anvilProc = runAnvil() - manager = waitFor setupOnchainGroupManager() + anvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) + manager = waitFor setupOnchainGroupManager(deployContracts = false) # mount rln-relay let wakuRlnConfig = getWakuRlnConfig(manager = manager, index = MembershipIndex(1)) diff --git a/tests/node/test_wakunode_lightpush.nim b/tests/node/test_wakunode_lightpush.nim index 12bfdddd8..29f72b2cc 100644 --- a/tests/node/test_wakunode_lightpush.nim +++ b/tests/node/test_wakunode_lightpush.nim @@ -135,8 +135,8 @@ suite "RLN Proofs as a Lightpush Service": server = newTestWakuNode(serverKey, parseIpAddress("0.0.0.0"), Port(0)) client = newTestWakuNode(clientKey, parseIpAddress("0.0.0.0"), Port(0)) - anvilProc = runAnvil() - manager = waitFor setupOnchainGroupManager() + anvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) + manager = waitFor setupOnchainGroupManager(deployContracts = false) # mount rln-relay let wakuRlnConfig = getWakuRlnConfig(manager = manager, index = MembershipIndex(1)) diff --git a/tests/waku_rln_relay/anvil_state/state-deployed-contracts-mint-and-approved.json.gz b/tests/waku_rln_relay/anvil_state/state-deployed-contracts-mint-and-approved.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..ceb081c77788d7b5a3a933b2d0510303247694a1 GIT binary patch literal 118346 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 z z!PQ=H9UMRgPp^bg#6+nM$#uYewvURinIg7E=RpjI?O)h4Z}0_C+|PHQ+NG=76{ip{ILSg)coeov+bz?ZkOg^7Y< z1EUvFDxQ?hOAs2``nc@2UU4s(V!N2izLx4-lsa7zljyic$+vhqRt58H`cl2H536oK z^g{G^(mQfRjyjs{8Nvmp>2f)Ho1zKP(O6s=i@%cX-t%rt1+)YERuYPvDqjA7;0x5v zsQ;Mvf4dKOD*E%^?}Pf0UuYn>P?P94^xe%>SMYpa!qXoui^BY2e^qI!`nrUB>KdYI zBi1IGPS30N=U4S^WUFWJ8AvnoLH+*Q*GRhk{yZHx)|rnNHbYkw@*jQuI+vp3nDWf3 zJhRe>pE*@otqYaJK+{``ii+VZE>h$2LK{vKKDT{PzG8nb@N+O=QT zY_ejX8HV`z32rNfJb%*+o$s}xGJLc+nyeVggt(gIRYyIDCOeL#&oxndwz@(uUAH}7 zPnD$#Rg8t%02A?Vsp1Z|bzLlo=k}mfkfP=lsh|-vsxx6Po?FlDNdIH#BFD_C-CAO5 zQ^8wnO1rNa<2X(saMzK|l0gCcfr!9Ccc{I;A_NT24yq6fX|a|1vWK-p2e%B?m}Kc8 zhaOaPj`>J^Vw56y_$b-KF=FUguA&&jPGJ{Ra)<3!;rbb9a$pcgt&qk+#!r?!-mxm% zrHp5ND~tN+rgSbrIlyCpOMo2ewTfsC!J0fk4!bAyZdl!x_%x92%|jWEw`Y-iwO~J< zhE_jbnUBr#PCxnU_LINX_v2sub<;a|U;ktLwdO^{jWA?<@eBlAtoQ_Xed_I4)TX3? zib{EoVv#@)PhmXK>29A6N}sXTUq|*wc-d!UrE5A@U2GWyW4ieJEuz(;QqQy)uMaVX zw-oL@R*Y?fmBmu1t1*6-Q57AK{sDfmeMa?E%cHjGY!si*K026*;uYC(?@sW3O^jcFwtzsPz>! z%PALS79p0-Y}uH-82F9wEf$vGG$$Izon#yncA{Y~*2HqSh%Q?Gy?MXi6xS;ZOEoKI zV=QXvJX!+w;vx7C{{U#=JvG=r$vd;dT5=GGrlbXJ;h-)55av`niKjas^E|e%yCsBx zlAy?$2QQVgWB=qz+P3rtr?Iloe2ekcCazcAm>hTCD?!4-d7q&6_F{D_Jl8#Iu(=C?np-=z$N&9|SIJ%z zWp7H9*!QSyF{YAu_O8re@C=UjB?9`vqhecxsUqeX`;?EtJw@eq`d83=BypT|qHCtBUYf zXI-%hV|li@CvOUir~E*-Q+2T|E;5Hv#CA4=dT~#k=Vg_?IG6|f&8An^xgY?TnL=0J zY7v_}Vb7H!EmoDzdh$lWh0Ice{I*|R(v6@r&c3>dLKb(mQctsD{zc⁡~sqblGgo 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 TCAs$Wd2js-fauz)YDEbEVqEpL literal 0 HcmV?d00001 diff --git a/tests/waku_rln_relay/test_rln_contract_deployment.nim b/tests/waku_rln_relay/test_rln_contract_deployment.nim new file mode 100644 index 000000000..5a9624ce8 --- /dev/null +++ b/tests/waku_rln_relay/test_rln_contract_deployment.nim @@ -0,0 +1,29 @@ +{.used.} + +{.push raises: [].} + +import std/[options, os], results, testutils/unittests, chronos, web3 + +import + waku/[ + waku_rln_relay, + waku_rln_relay/conversion_utils, + waku_rln_relay/group_manager/on_chain/group_manager, + ], + ./utils_onchain + +suite "Token and RLN Contract Deployment": + test "anvil should dump state to file on exit": + # git will ignore this file, if the contract has been updated and the state file needs to be regenerated then this file can be renamed to replace the one in the repo (tests/waku_rln_relay/anvil_state/tests/waku_rln_relay/anvil_state/state-deployed-contracts-mint-and-approved.json) + let testStateFile = some("tests/waku_rln_relay/anvil_state/anvil_state.ignore.json") + let anvilProc = runAnvil(stateFile = testStateFile, dumpStateOnExit = true) + let manager = waitFor setupOnchainGroupManager(deployContracts = true) + + stopAnvil(anvilProc) + + check: + fileExists(testStateFile.get()) + + #The test should still pass even if thie compression fails + compressGzipFile(testStateFile.get(), testStateFile.get() & ".gz").isOkOr: + error "Failed to compress state file", error = error diff --git a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim index cf697961a..aac900911 100644 --- a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim +++ b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim @@ -33,8 +33,8 @@ suite "Onchain group manager": var manager {.threadVar.}: OnchainGroupManager setup: - anvilProc = runAnvil() - manager = waitFor setupOnchainGroupManager() + anvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) + manager = waitFor setupOnchainGroupManager(deployContracts = false) teardown: stopAnvil(anvilProc) diff --git a/tests/waku_rln_relay/test_waku_rln_relay.nim b/tests/waku_rln_relay/test_waku_rln_relay.nim index 0bbb448e1..ea3a5ca62 100644 --- a/tests/waku_rln_relay/test_waku_rln_relay.nim +++ b/tests/waku_rln_relay/test_waku_rln_relay.nim @@ -27,8 +27,8 @@ suite "Waku rln relay": var manager {.threadVar.}: OnchainGroupManager setup: - anvilProc = runAnvil() - manager = waitFor setupOnchainGroupManager() + anvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) + manager = waitFor setupOnchainGroupManager(deployContracts = false) teardown: stopAnvil(anvilProc) diff --git a/tests/waku_rln_relay/test_wakunode_rln_relay.nim b/tests/waku_rln_relay/test_wakunode_rln_relay.nim index 7308ae257..1850b5277 100644 --- a/tests/waku_rln_relay/test_wakunode_rln_relay.nim +++ b/tests/waku_rln_relay/test_wakunode_rln_relay.nim @@ -30,8 +30,8 @@ procSuite "WakuNode - RLN relay": var manager {.threadVar.}: OnchainGroupManager setup: - anvilProc = runAnvil() - manager = waitFor setupOnchainGroupManager() + anvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) + manager = waitFor setupOnchainGroupManager(deployContracts = false) teardown: stopAnvil(anvilProc) diff --git a/tests/waku_rln_relay/utils_onchain.nim b/tests/waku_rln_relay/utils_onchain.nim index 06e4fcdcf..d8bb13a62 100644 --- a/tests/waku_rln_relay/utils_onchain.nim +++ b/tests/waku_rln_relay/utils_onchain.nim @@ -3,7 +3,7 @@ {.push raises: [].} import - std/[options, os, osproc, deques, streams, strutils, tempfiles, strformat], + std/[options, os, osproc, streams, strutils, strformat], results, stew/byteutils, testutils/unittests, @@ -14,7 +14,6 @@ import web3/conversions, web3/eth_api_types, json_rpc/rpcclient, - json, libp2p/crypto/crypto, eth/keys, results @@ -24,25 +23,19 @@ import waku_rln_relay, waku_rln_relay/protocol_types, waku_rln_relay/constants, - waku_rln_relay/contract, waku_rln_relay/rln, ], - ../testlib/common, - ./utils + ../testlib/common const CHAIN_ID* = 1234'u256 -template skip0xPrefix(hexStr: string): int = - ## Returns the index of the first meaningful char in `hexStr` by skipping - ## "0x" prefix - if hexStr.len > 1 and hexStr[0] == '0' and hexStr[1] in {'x', 'X'}: 2 else: 0 - -func strip0xPrefix(s: string): string = - let prefixLen = skip0xPrefix(s) - if prefixLen != 0: - s[prefixLen .. ^1] - else: - s +# Path to the file which Anvil loads at startup to initialize the chain with pre-deployed contracts, an account funded with tokens and approved for spending +const DEFAULT_ANVIL_STATE_PATH* = + "tests/waku_rln_relay/anvil_state/state-deployed-contracts-mint-and-approved.json.gz" +# The contract address of the TestStableToken used for the RLN Membership registration fee +const TOKEN_ADDRESS* = "0x5FbDB2315678afecb367f032d93F642f64180aa3" +# The contract address used ti interact with the WakuRLNV2 contract via the proxy +const WAKU_RLNV2_PROXY_ADDRESS* = "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707" proc generateCredentials*(): IdentityCredential = let credRes = membershipKeyGen() @@ -106,7 +99,7 @@ proc sendMintCall( recipientAddress: Address, amountTokens: UInt256, recipientBalanceBeforeExpectedTokens: Option[UInt256] = none(UInt256), -): Future[TxHash] {.async.} = +): Future[void] {.async.} = let doBalanceAssert = recipientBalanceBeforeExpectedTokens.isSome() if doBalanceAssert: @@ -142,7 +135,7 @@ proc sendMintCall( tx.data = Opt.some(byteutils.hexToSeqByte(mintCallData)) trace "Sending mint call" - let txHash = await web3.send(tx) + discard await web3.send(tx) let balanceOfSelector = "0x70a08231" let balanceCallData = balanceOfSelector & paddedAddress @@ -157,8 +150,6 @@ proc sendMintCall( assert balanceAfterMint == balanceAfterExpectedTokens, fmt"Balance is {balanceAfterMint} after transfer but expected {balanceAfterExpectedTokens}" - return txHash - # Check how many tokens a spender (the RLN contract) is allowed to spend on behalf of the owner (account which wishes to register a membership) proc checkTokenAllowance( web3: Web3, tokenAddress: Address, owner: Address, spender: Address @@ -487,20 +478,64 @@ proc getAnvilPath*(): string = anvilPath = joinPath(anvilPath, ".foundry/bin/anvil") return $anvilPath +proc decompressGzipFile*( + compressedPath: string, targetPath: string +): Result[void, string] = + ## Decompress a gzipped file using the gunzip command-line utility + let cmd = fmt"gunzip -c {compressedPath} > {targetPath}" + + try: + let (output, exitCode) = execCmdEx(cmd) + if exitCode != 0: + return err( + "Failed to decompress '" & compressedPath & "' to '" & targetPath & "': " & + output + ) + except OSError as e: + return err("Failed to execute gunzip command: " & e.msg) + except IOError as e: + return err("Failed to execute gunzip command: " & e.msg) + + ok() + +proc compressGzipFile*(sourcePath: string, targetPath: string): Result[void, string] = + ## Compress a file with gzip using the gzip command-line utility + let cmd = fmt"gzip -c {sourcePath} > {targetPath}" + + try: + let (output, exitCode) = execCmdEx(cmd) + if exitCode != 0: + return err( + "Failed to compress '" & sourcePath & "' to '" & targetPath & "': " & output + ) + except OSError as e: + return err("Failed to execute gzip command: " & e.msg) + except IOError as e: + return err("Failed to execute gzip command: " & e.msg) + + ok() + # Runs Anvil daemon -proc runAnvil*(port: int = 8540, chainId: string = "1234"): Process = +proc runAnvil*( + port: int = 8540, + chainId: string = "1234", + stateFile: Option[string] = none(string), + dumpStateOnExit: bool = false, +): Process = # Passed options are # --port Port to listen on. # --gas-limit Sets the block gas limit in WEI. # --balance The default account balance, specified in ether. # --chain-id Chain ID of the network. + # --load-state Initialize the chain from a previously saved state snapshot (read-only) + # --dump-state Dump the state on exit to the given file (write-only) # See anvil documentation https://book.getfoundry.sh/reference/anvil/ for more details try: let anvilPath = getAnvilPath() info "Anvil path", anvilPath - let runAnvil = startProcess( - anvilPath, - args = [ + + var args = + @[ "--port", $port, "--gas-limit", @@ -509,9 +544,54 @@ proc runAnvil*(port: int = 8540, chainId: string = "1234"): Process = "1000000000", "--chain-id", $chainId, - ], - options = {poUsePath, poStdErrToStdOut}, - ) + ] + + # Add state file argument if provided + if stateFile.isSome(): + var statePath = stateFile.get() + info "State file parameter provided", + statePath = statePath, + dumpStateOnExit = dumpStateOnExit, + absolutePath = absolutePath(statePath) + + # Check if the file is gzip compressed and handle decompression + if statePath.endsWith(".gz"): + let decompressedPath = statePath[0 .. ^4] # Remove .gz extension + debug "Gzip compressed state file detected", + compressedPath = statePath, decompressedPath = decompressedPath + + if not fileExists(decompressedPath): + decompressGzipFile(statePath, decompressedPath).isOkOr: + error "Failed to decompress state file", error = error + return nil + + statePath = decompressedPath + + if dumpStateOnExit: + # Ensure the directory exists + let stateDir = parentDir(statePath) + if not dirExists(stateDir): + createDir(stateDir) + # Fresh deployment: start clean and dump state on exit + args.add("--dump-state") + args.add(statePath) + debug "Anvil configured to dump state on exit", path = statePath + else: + # Using cache: only load state, don't overwrite it (preserves clean cached state) + if fileExists(statePath): + args.add("--load-state") + args.add(statePath) + debug "Anvil configured to load state file (read-only)", path = statePath + else: + warn "State file does not exist, anvil will start fresh", + path = statePath, absolutePath = absolutePath(statePath) + else: + info "No state file provided, anvil will start fresh without state persistence" + + info "Starting anvil with arguments", args = args.join(" ") + + let runAnvil = + startProcess(anvilPath, args = args, options = {poUsePath, poStdErrToStdOut}) let anvilPID = runAnvil.processID # We read stdout from Anvil to see when daemon is ready @@ -549,7 +629,14 @@ proc stopAnvil*(runAnvil: Process) {.used.} = # Send termination signals when not defined(windows): discard execCmdEx(fmt"kill -TERM {anvilPID}") - discard execCmdEx(fmt"kill -9 {anvilPID}") + # Wait for graceful shutdown to allow state dumping + sleep(200) + # Only force kill if process is still running + let checkResult = execCmdEx(fmt"kill -0 {anvilPID} 2>/dev/null") + if checkResult.exitCode == 0: + info "Anvil process still running after TERM signal, sending KILL", + anvilPID = anvilPID + discard execCmdEx(fmt"kill -9 {anvilPID}") else: discard execCmdEx(fmt"taskkill /F /PID {anvilPID}") @@ -560,52 +647,100 @@ proc stopAnvil*(runAnvil: Process) {.used.} = info "Error stopping Anvil daemon", anvilPID = anvilPID, error = e.msg proc setupOnchainGroupManager*( - ethClientUrl: string = EthClient, amountEth: UInt256 = 10.u256 + ethClientUrl: string = EthClient, + amountEth: UInt256 = 10.u256, + deployContracts: bool = true, ): Future[OnchainGroupManager] {.async.} = + ## Setup an onchain group manager for testing + ## If deployContracts is false, it will assume that the Anvil testnet already has the required contracts deployed, this significantly speeds up test runs. + ## To run Anvil with a cached state file containing pre-deployed contracts, see runAnvil documentation. + ## + ## To generate/update the cached state file: + ## 1. Call runAnvil with stateFile and dumpStateOnExit=true + ## 2. Run setupOnchainGroupManager with deployContracts=true to deploy contracts + ## 3. The state will be saved to the specified file when anvil exits + ## 4. Commit this file to git + ## + ## To use cached state: + ## 1. Call runAnvil with stateFile and dumpStateOnExit=false + ## 2. Anvil loads state in read-only mode (won't overwrite the cached file) + ## 3. Call setupOnchainGroupManager with deployContracts=false + ## 4. Tests run fast using pre-deployed contracts let rlnInstanceRes = createRlnInstance() check: rlnInstanceRes.isOk() let rlnInstance = rlnInstanceRes.get() - # connect to the eth client let web3 = await newWeb3(ethClientUrl) let accounts = await web3.provider.eth_accounts() web3.defaultAccount = accounts[1] - let (privateKey, acc) = createEthAccount(web3) + var privateKey: keys.PrivateKey + var acc: Address + var testTokenAddress: Address + var contractAddress: Address - # we just need to fund the default account - # the send procedure returns a tx hash that we don't use, hence discard - discard await sendEthTransfer( - web3, web3.defaultAccount, acc, ethToWei(1000.u256), some(0.u256) - ) + if not deployContracts: + info "Using contract addresses from constants" - let testTokenAddress = (await deployTestToken(privateKey, acc, web3)).valueOr: - assert false, "Failed to deploy test token contract: " & $error - return + testTokenAddress = Address(hexToByteArray[20](TOKEN_ADDRESS)) + contractAddress = Address(hexToByteArray[20](WAKU_RLNV2_PROXY_ADDRESS)) - # mint the token from the generated account - discard await sendMintCall( - web3, web3.defaultAccount, testTokenAddress, acc, ethToWei(1000.u256), some(0.u256) - ) + (privateKey, acc) = createEthAccount(web3) - let contractAddress = (await executeForgeContractDeployScripts(privateKey, acc, web3)).valueOr: - assert false, "Failed to deploy RLN contract: " & $error - return + # Fund the test account + discard await sendEthTransfer(web3, web3.defaultAccount, acc, ethToWei(1000.u256)) - # If the generated account wishes to register a membership, it needs to approve the contract to spend its tokens - let tokenApprovalResult = await approveTokenAllowanceAndVerify( - web3, - acc, - privateKey, - testTokenAddress, - contractAddress, - ethToWei(200.u256), - some(0.u256), - ) + # Mint tokens to the test account + await sendMintCall( + web3, web3.defaultAccount, testTokenAddress, acc, ethToWei(1000.u256) + ) - assert tokenApprovalResult.isOk, tokenApprovalResult.error() + # Approve the contract to spend tokens + let tokenApprovalResult = await approveTokenAllowanceAndVerify( + web3, acc, privateKey, testTokenAddress, contractAddress, ethToWei(200.u256) + ) + assert tokenApprovalResult.isOk(), tokenApprovalResult.error + else: + info "Performing Token and RLN contracts deployment" + (privateKey, acc) = createEthAccount(web3) + + # fund the default account + discard await sendEthTransfer( + web3, web3.defaultAccount, acc, ethToWei(1000.u256), some(0.u256) + ) + + testTokenAddress = (await deployTestToken(privateKey, acc, web3)).valueOr: + assert false, "Failed to deploy test token contract: " & $error + return + + # mint the token from the generated account + await sendMintCall( + web3, + web3.defaultAccount, + testTokenAddress, + acc, + ethToWei(1000.u256), + some(0.u256), + ) + + contractAddress = (await executeForgeContractDeployScripts(privateKey, acc, web3)).valueOr: + assert false, "Failed to deploy RLN contract: " & $error + return + + # If the generated account wishes to register a membership, it needs to approve the contract to spend its tokens + let tokenApprovalResult = await approveTokenAllowanceAndVerify( + web3, + acc, + privateKey, + testTokenAddress, + contractAddress, + ethToWei(200.u256), + some(0.u256), + ) + + assert tokenApprovalResult.isOk(), tokenApprovalResult.error let manager = OnchainGroupManager( ethClientUrls: @[ethClientUrl], diff --git a/tests/wakunode_rest/test_rest_health.nim b/tests/wakunode_rest/test_rest_health.nim index dacfd801e..ed8269f55 100644 --- a/tests/wakunode_rest/test_rest_health.nim +++ b/tests/wakunode_rest/test_rest_health.nim @@ -41,8 +41,8 @@ suite "Waku v2 REST API - health": var manager {.threadVar.}: OnchainGroupManager setup: - anvilProc = runAnvil() - manager = waitFor setupOnchainGroupManager() + anvilProc = runAnvil(stateFile = some(DEFAULT_ANVIL_STATE_PATH)) + manager = waitFor setupOnchainGroupManager(deployContracts = false) teardown: stopAnvil(anvilProc) diff --git a/vendor/waku-rlnv2-contract b/vendor/waku-rlnv2-contract index 900d4f95e..8a338f354 160000 --- a/vendor/waku-rlnv2-contract +++ b/vendor/waku-rlnv2-contract @@ -1 +1 @@ -Subproject commit 900d4f95e0e618bdeb4c241f7a4b6347df6bb950 +Subproject commit 8a338f354481e8a3f3d64a72e38fad4c62e32dcd diff --git a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim index e8af61682..bdb272c1f 100644 --- a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim +++ b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim @@ -242,7 +242,7 @@ method register*( fetchedGasPrice = fetchedGasPrice, gasPrice = calculatedGasPrice calculatedGasPrice let idCommitmentHex = identityCredential.idCommitment.inHex() - info "identityCredential idCommitmentHex", idCommitment = idCommitmentHex + debug "identityCredential idCommitmentHex", idCommitment = idCommitmentHex let idCommitment = identityCredential.idCommitment.toUInt256() let idCommitmentsToErase: seq[UInt256] = @[] info "registering the member", @@ -259,11 +259,10 @@ method register*( var tsReceipt: ReceiptObject g.retryWrapper(tsReceipt, "Failed to get the transaction receipt"): await ethRpc.getMinedTransactionReceipt(txHash) - info "registration transaction mined", txHash = txHash + debug "registration transaction mined", txHash = txHash g.registrationTxHash = some(txHash) # the receipt topic holds the hash of signature of the raised events - # TODO: make this robust. search within the event list for the event - info "ts receipt", receipt = tsReceipt[] + debug "ts receipt", receipt = tsReceipt[] if tsReceipt.status.isNone(): raise newException(ValueError, "Transaction failed: status is None") @@ -272,18 +271,27 @@ method register*( ValueError, "Transaction failed with status: " & $tsReceipt.status.get() ) - ## Extract MembershipRegistered event from transaction logs (third event) - let thirdTopic = tsReceipt.logs[2].topics[0] - info "third topic", thirdTopic = thirdTopic - if thirdTopic != - cast[FixedBytes[32]](keccak.keccak256.digest( - "MembershipRegistered(uint256,uint256,uint32)" - ).data): - raise newException(ValueError, "register: unexpected event signature") + ## Search through all transaction logs to find the MembershipRegistered event + let expectedEventSignature = cast[FixedBytes[32]](keccak.keccak256.digest( + "MembershipRegistered(uint256,uint256,uint32)" + ).data) - ## Parse MembershipRegistered event data: rateCommitment(256) || membershipRateLimit(256) || index(32) - let arguments = tsReceipt.logs[2].data - info "tx log data", arguments = arguments + var membershipRegisteredLog: Option[LogObject] + for log in tsReceipt.logs: + if log.topics.len > 0 and log.topics[0] == expectedEventSignature: + membershipRegisteredLog = some(log) + break + + if membershipRegisteredLog.isNone(): + raise newException( + ValueError, "register: MembershipRegistered event not found in transaction logs" + ) + + let registrationLog = membershipRegisteredLog.get() + + ## Parse MembershipRegistered event data: idCommitment(256) || membershipRateLimit(256) || index(32) + let arguments = registrationLog.data + trace "registration transaction log data", arguments = arguments let ## Extract membership index from transaction log data (big endian) membershipIndex = UInt256.fromBytesBE(arguments[64 .. 95]) From 7920368a36687cd5f12afa52d59866792d8457ca Mon Sep 17 00:00:00 2001 From: Fabiana Cecin Date: Mon, 8 Dec 2025 06:34:57 -0300 Subject: [PATCH 17/18] fix: remove ENR cache from peer exchange (#3652) * remove WakuPeerExchange.enrCache * add forEnrPeers to support fast PeerStore search * add getEnrsFromStore * fix peer exchange tests --- tests/node/test_wakunode_peer_exchange.nim | 18 ++-- tests/waku_peer_exchange/test_protocol.nim | 100 +++++++++------------ waku/node/peer_manager/waku_peer_store.nim | 14 +++ waku/node/waku_node.nim | 3 - waku/waku_peer_exchange/protocol.nim | 98 +++++++++----------- 5 files changed, 108 insertions(+), 125 deletions(-) diff --git a/tests/node/test_wakunode_peer_exchange.nim b/tests/node/test_wakunode_peer_exchange.nim index 9b0ea4c40..e6649c455 100644 --- a/tests/node/test_wakunode_peer_exchange.nim +++ b/tests/node/test_wakunode_peer_exchange.nim @@ -66,15 +66,17 @@ suite "Waku Peer Exchange": suite "fetchPeerExchangePeers": var node2 {.threadvar.}: WakuNode + var node3 {.threadvar.}: WakuNode asyncSetup: node = newTestWakuNode(generateSecp256k1Key(), bindIp, bindPort) node2 = newTestWakuNode(generateSecp256k1Key(), bindIp, bindPort) + node3 = newTestWakuNode(generateSecp256k1Key(), bindIp, bindPort) - await allFutures(node.start(), node2.start()) + await allFutures(node.start(), node2.start(), node3.start()) asyncTeardown: - await allFutures(node.stop(), node2.stop()) + await allFutures(node.stop(), node2.stop(), node3.stop()) asyncTest "Node fetches without mounting peer exchange": # When a node, without peer exchange mounted, fetches peers @@ -104,12 +106,10 @@ suite "Waku Peer Exchange": await allFutures([node.mountPeerExchangeClient(), node2.mountPeerExchange()]) check node.peerManager.switch.peerStore.peers.len == 0 - # Mock that we discovered a node (to avoid running discv5) - var enr = enr.Record() - assert enr.fromUri( - "enr:-Iu4QGNuTvNRulF3A4Kb9YHiIXLr0z_CpvWkWjWKU-o95zUPR_In02AWek4nsSk7G_-YDcaT4bDRPzt5JIWvFqkXSNcBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQKp9VzU2FAh7fwOwSpg1M_Ekz4zzl0Fpbg6po2ZwgVwQYN0Y3CC6mCFd2FrdTIB" - ), "Failed to parse ENR" - node2.wakuPeerExchange.enrCache.add(enr) + # Simulate node2 discovering node3 via Discv5 + var rpInfo = node3.peerInfo.toRemotePeerInfo() + rpInfo.enr = some(node3.enr) + node2.peerManager.addPeer(rpInfo, PeerOrigin.Discv5) # Set node2 as service peer (default one) for px protocol node.peerManager.addServicePeer( @@ -121,10 +121,8 @@ suite "Waku Peer Exchange": check res.tryGet() == 1 # Check that the peer ended up in the peerstore - let rpInfo = enr.toRemotePeerInfo.get() check: node.peerManager.switch.peerStore.peers.anyIt(it.peerId == rpInfo.peerId) - node.peerManager.switch.peerStore.peers.anyIt(it.addrs == rpInfo.addrs) suite "setPeerExchangePeer": var node2 {.threadvar.}: WakuNode diff --git a/tests/waku_peer_exchange/test_protocol.nim b/tests/waku_peer_exchange/test_protocol.nim index 204338a85..74cdba110 100644 --- a/tests/waku_peer_exchange/test_protocol.nim +++ b/tests/waku_peer_exchange/test_protocol.nim @@ -142,9 +142,13 @@ suite "Waku Peer Exchange": newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) node2 = newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) + node3 = + newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) + node4 = + newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) # Start and mount peer exchange - await allFutures([node1.start(), node2.start()]) + await allFutures([node1.start(), node2.start(), node3.start(), node4.start()]) await allFutures([node1.mountPeerExchange(), node2.mountPeerExchangeClient()]) # Create connection @@ -154,18 +158,15 @@ suite "Waku Peer Exchange": require: connOpt.isSome - # Create some enr and add to peer exchange (simulating disv5) - var enr1, enr2 = enr.Record() - check enr1.fromUri( - "enr:-Iu4QGNuTvNRulF3A4Kb9YHiIXLr0z_CpvWkWjWKU-o95zUPR_In02AWek4nsSk7G_-YDcaT4bDRPzt5JIWvFqkXSNcBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQKp9VzU2FAh7fwOwSpg1M_Ekz4zzl0Fpbg6po2ZwgVwQYN0Y3CC6mCFd2FrdTIB" - ) - check enr2.fromUri( - "enr:-Iu4QGJllOWlviPIh_SGR-VVm55nhnBIU5L-s3ran7ARz_4oDdtJPtUs3Bc5aqZHCiPQX6qzNYF2ARHER0JPX97TFbEBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQP3ULycvday4EkvtVu0VqbBdmOkbfVLJx8fPe0lE_dRkIN0Y3CC6mCFd2FrdTIB" - ) + # Simulate node1 discovering node3 via Discv5 + var info3 = node3.peerInfo.toRemotePeerInfo() + info3.enr = some(node3.enr) + node1.peerManager.addPeer(info3, PeerOrigin.Discv5) - # Mock that we have discovered these enrs - node1.wakuPeerExchange.enrCache.add(enr1) - node1.wakuPeerExchange.enrCache.add(enr2) + # Simulate node1 discovering node4 via Discv5 + var info4 = node4.peerInfo.toRemotePeerInfo() + info4.enr = some(node4.enr) + node1.peerManager.addPeer(info4, PeerOrigin.Discv5) # Request 2 peer from px. Test all request variants let response1 = await node2.wakuPeerExchangeClient.request(2) @@ -185,12 +186,12 @@ suite "Waku Peer Exchange": response3.get().peerInfos.len == 2 # Since it can return duplicates test that at least one of the enrs is in the response - response1.get().peerInfos.anyIt(it.enr == enr1.raw) or - response1.get().peerInfos.anyIt(it.enr == enr2.raw) - response2.get().peerInfos.anyIt(it.enr == enr1.raw) or - response2.get().peerInfos.anyIt(it.enr == enr2.raw) - response3.get().peerInfos.anyIt(it.enr == enr1.raw) or - response3.get().peerInfos.anyIt(it.enr == enr2.raw) + response1.get().peerInfos.anyIt(it.enr == node3.enr.raw) or + response1.get().peerInfos.anyIt(it.enr == node4.enr.raw) + response2.get().peerInfos.anyIt(it.enr == node3.enr.raw) or + response2.get().peerInfos.anyIt(it.enr == node4.enr.raw) + response3.get().peerInfos.anyIt(it.enr == node3.enr.raw) or + response3.get().peerInfos.anyIt(it.enr == node4.enr.raw) asyncTest "Request fails gracefully": let @@ -265,8 +266,8 @@ suite "Waku Peer Exchange": peerInfo2.origin = PeerOrigin.Discv5 check: - not poolFilter(cluster, peerInfo1) - poolFilter(cluster, peerInfo2) + poolFilter(cluster, peerInfo1).isErr() + poolFilter(cluster, peerInfo2).isOk() asyncTest "Request 0 peers, with 1 peer in PeerExchange": # Given two valid nodes with PeerExchange @@ -275,9 +276,11 @@ suite "Waku Peer Exchange": newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) node2 = newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) + node3 = + newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) # Start and mount peer exchange - await allFutures([node1.start(), node2.start()]) + await allFutures([node1.start(), node2.start(), node3.start()]) await allFutures([node1.mountPeerExchange(), node2.mountPeerExchangeClient()]) # Connect the nodes @@ -286,12 +289,10 @@ suite "Waku Peer Exchange": ) assert dialResponse.isSome - # Mock that we have discovered one enr - var record = enr.Record() - check record.fromUri( - "enr:-Iu4QGNuTvNRulF3A4Kb9YHiIXLr0z_CpvWkWjWKU-o95zUPR_In02AWek4nsSk7G_-YDcaT4bDRPzt5JIWvFqkXSNcBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQKp9VzU2FAh7fwOwSpg1M_Ekz4zzl0Fpbg6po2ZwgVwQYN0Y3CC6mCFd2FrdTIB" - ) - node1.wakuPeerExchange.enrCache.add(record) + # Simulate node1 discovering node3 via Discv5 + var info3 = node3.peerInfo.toRemotePeerInfo() + info3.enr = some(node3.enr) + node1.peerManager.addPeer(info3, PeerOrigin.Discv5) # When requesting 0 peers let response = await node2.wakuPeerExchangeClient.request(0) @@ -312,13 +313,6 @@ suite "Waku Peer Exchange": await allFutures([node1.start(), node2.start()]) await allFutures([node1.mountPeerExchangeClient(), node2.mountPeerExchange()]) - # Mock that we have discovered one enr - var record = enr.Record() - check record.fromUri( - "enr:-Iu4QGNuTvNRulF3A4Kb9YHiIXLr0z_CpvWkWjWKU-o95zUPR_In02AWek4nsSk7G_-YDcaT4bDRPzt5JIWvFqkXSNcBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQKp9VzU2FAh7fwOwSpg1M_Ekz4zzl0Fpbg6po2ZwgVwQYN0Y3CC6mCFd2FrdTIB" - ) - node2.wakuPeerExchange.enrCache.add(record) - # When making any request with an invalid peer info var remotePeerInfo2 = node2.peerInfo.toRemotePeerInfo() remotePeerInfo2.peerId.data.add(255.byte) @@ -362,17 +356,17 @@ suite "Waku Peer Exchange": newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) node2 = newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) + node3 = + newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) # Start and mount peer exchange - await allFutures([node1.start(), node2.start()]) + await allFutures([node1.start(), node2.start(), node3.start()]) await allFutures([node1.mountPeerExchange(), node2.mountPeerExchange()]) - # Mock that we have discovered these enrs - var enr1 = enr.Record() - check enr1.fromUri( - "enr:-Iu4QGNuTvNRulF3A4Kb9YHiIXLr0z_CpvWkWjWKU-o95zUPR_In02AWek4nsSk7G_-YDcaT4bDRPzt5JIWvFqkXSNcBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQKp9VzU2FAh7fwOwSpg1M_Ekz4zzl0Fpbg6po2ZwgVwQYN0Y3CC6mCFd2FrdTIB" - ) - node1.wakuPeerExchange.enrCache.add(enr1) + # Simulate node1 discovering node3 via Discv5 + var info3 = node3.peerInfo.toRemotePeerInfo() + info3.enr = some(node3.enr) + node1.peerManager.addPeer(info3, PeerOrigin.Discv5) # Create connection let connOpt = await node2.peerManager.dialPeer( @@ -396,7 +390,7 @@ suite "Waku Peer Exchange": check: decodedBuff.get().response.status_code == PeerExchangeResponseStatusCode.SUCCESS decodedBuff.get().response.peerInfos.len == 1 - decodedBuff.get().response.peerInfos[0].enr == enr1.raw + decodedBuff.get().response.peerInfos[0].enr == node3.enr.raw asyncTest "RateLimit as expected": let @@ -404,9 +398,11 @@ suite "Waku Peer Exchange": newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) node2 = newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) + node3 = + newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) # Start and mount peer exchange - await allFutures([node1.start(), node2.start()]) + await allFutures([node1.start(), node2.start(), node3.start()]) await allFutures( [ node1.mountPeerExchange(rateLimit = (1, 150.milliseconds)), @@ -414,6 +410,11 @@ suite "Waku Peer Exchange": ] ) + # Simulate node1 discovering nodeA via Discv5 + var info3 = node3.peerInfo.toRemotePeerInfo() + info3.enr = some(node3.enr) + node1.peerManager.addPeer(info3, PeerOrigin.Discv5) + # Create connection let connOpt = await node2.peerManager.dialPeer( node1.switch.peerInfo.toRemotePeerInfo(), WakuPeerExchangeCodec @@ -421,19 +422,6 @@ suite "Waku Peer Exchange": require: connOpt.isSome - # Create some enr and add to peer exchange (simulating disv5) - var enr1, enr2 = enr.Record() - check enr1.fromUri( - "enr:-Iu4QGNuTvNRulF3A4Kb9YHiIXLr0z_CpvWkWjWKU-o95zUPR_In02AWek4nsSk7G_-YDcaT4bDRPzt5JIWvFqkXSNcBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQKp9VzU2FAh7fwOwSpg1M_Ekz4zzl0Fpbg6po2ZwgVwQYN0Y3CC6mCFd2FrdTIB" - ) - check enr2.fromUri( - "enr:-Iu4QGJllOWlviPIh_SGR-VVm55nhnBIU5L-s3ran7ARz_4oDdtJPtUs3Bc5aqZHCiPQX6qzNYF2ARHER0JPX97TFbEBgmlkgnY0gmlwhE0WsGeJc2VjcDI1NmsxoQP3ULycvday4EkvtVu0VqbBdmOkbfVLJx8fPe0lE_dRkIN0Y3CC6mCFd2FrdTIB" - ) - - # Mock that we have discovered these enrs - node1.wakuPeerExchange.enrCache.add(enr1) - node1.wakuPeerExchange.enrCache.add(enr2) - await sleepAsync(150.milliseconds) # Request 2 peer from px. Test all request variants diff --git a/waku/node/peer_manager/waku_peer_store.nim b/waku/node/peer_manager/waku_peer_store.nim index 9cde53fe1..b7f2669e5 100644 --- a/waku/node/peer_manager/waku_peer_store.nim +++ b/waku/node/peer_manager/waku_peer_store.nim @@ -227,3 +227,17 @@ proc getPeersByCapability*( ): seq[RemotePeerInfo] = return peerStore.peers.filterIt(it.enr.isSome() and it.enr.get().supportsCapability(cap)) + +template forEnrPeers*( + peerStore: PeerStore, + peerId, peerConnectedness, peerOrigin, peerEnrRecord, body: untyped, +) = + let enrBook = peerStore[ENRBook] + let connBook = peerStore[ConnectionBook] + let sourceBook = peerStore[SourceBook] + for pid, enrRecord in tables.pairs(enrBook.book): + let peerId {.inject.} = pid + let peerConnectedness {.inject.} = connBook.book.getOrDefault(pid, NotConnected) + let peerOrigin {.inject.} = sourceBook.book.getOrDefault(pid, UnknownOrigin) + let peerEnrRecord {.inject.} = enrRecord + body diff --git a/waku/node/waku_node.nim b/waku/node/waku_node.nim index 65b2093bb..07e36dd13 100644 --- a/waku/node/waku_node.nim +++ b/waku/node/waku_node.nim @@ -525,9 +525,6 @@ proc stop*(node: WakuNode) {.async.} = if not node.wakuStoreTransfer.isNil(): node.wakuStoreTransfer.stop() - if not node.wakuPeerExchange.isNil() and not node.wakuPeerExchange.pxLoopHandle.isNil(): - await node.wakuPeerExchange.pxLoopHandle.cancelAndWait() - if not node.wakuPeerExchangeClient.isNil() and not node.wakuPeerExchangeClient.pxLoopHandle.isNil(): await node.wakuPeerExchangeClient.pxLoopHandle.cancelAndWait() diff --git a/waku/waku_peer_exchange/protocol.nim b/waku/waku_peer_exchange/protocol.nim index cf7ebc2a7..b99f5eabf 100644 --- a/waku/waku_peer_exchange/protocol.nim +++ b/waku/waku_peer_exchange/protocol.nim @@ -22,7 +22,6 @@ export WakuPeerExchangeCodec declarePublicGauge waku_px_peers_received_unknown, "number of previously unknown ENRs received via peer exchange" -declarePublicGauge waku_px_peers_cached, "number of peer exchange peer ENRs cached" declarePublicCounter waku_px_errors, "number of peer exchange errors", ["type"] declarePublicCounter waku_px_peers_sent, "number of ENRs sent to peer exchange requesters" @@ -32,11 +31,9 @@ logScope: type WakuPeerExchange* = ref object of LPProtocol peerManager*: PeerManager - enrCache*: seq[enr.Record] cluster*: Option[uint16] # todo: next step: ring buffer; future: implement cache satisfying https://rfc.vac.dev/spec/34/ requestRateLimiter*: RequestRateLimiter - pxLoopHandle*: Future[void] proc respond( wpx: WakuPeerExchange, enrs: seq[enr.Record], conn: Connection @@ -79,61 +76,50 @@ proc respondError( return ok() -proc getEnrsFromCache( - wpx: WakuPeerExchange, numPeers: uint64 -): seq[enr.Record] {.gcsafe.} = - if wpx.enrCache.len() == 0: - info "peer exchange ENR cache is empty" - return @[] - - # copy and shuffle - randomize() - var shuffledCache = wpx.enrCache - shuffledCache.shuffle() - - # return numPeers or less if cache is smaller - return shuffledCache[0 ..< min(shuffledCache.len.int, numPeers.int)] - -proc poolFilter*(cluster: Option[uint16], peer: RemotePeerInfo): bool = - if peer.origin != Discv5: - trace "peer not from discv5", peer = $peer, origin = $peer.origin - return false +proc poolFilter*( + cluster: Option[uint16], origin: PeerOrigin, enr: enr.Record +): Result[void, string] = + if origin != Discv5: + trace "peer not from discv5", origin = $origin + return err("peer not from discv5: " & $origin) + if cluster.isSome() and enr.isClusterMismatched(cluster.get()): + trace "peer has mismatching cluster" + return err("peer has mismatching cluster") + return ok() +proc poolFilter*(cluster: Option[uint16], peer: RemotePeerInfo): Result[void, string] = if peer.enr.isNone(): info "peer has no ENR", peer = $peer - return false + return err("peer has no ENR: " & $peer) + return poolFilter(cluster, peer.origin, peer.enr.get()) - if cluster.isSome() and peer.enr.get().isClusterMismatched(cluster.get()): - info "peer has mismatching cluster", peer = $peer - return false - - return true - -proc populateEnrCache(wpx: WakuPeerExchange) = - # share only peers that i) are reachable ii) come from discv5 iii) share cluster - let withEnr = wpx.peerManager.switch.peerStore.getReachablePeers().filterIt( - poolFilter(wpx.cluster, it) - ) - - # either what we have or max cache size - var newEnrCache = newSeq[enr.Record](0) - for i in 0 ..< min(withEnr.len, MaxPeersCacheSize): - newEnrCache.add(withEnr[i].enr.get()) - - # swap cache for new - wpx.enrCache = newEnrCache - trace "ENR cache populated" - -proc updatePxEnrCache(wpx: WakuPeerExchange) {.async.} = - # try more aggressively to fill the cache at startup - var attempts = 50 - while wpx.enrCache.len < MaxPeersCacheSize and attempts > 0: - attempts -= 1 - wpx.populateEnrCache() - await sleepAsync(1.seconds) - - heartbeat "Updating px enr cache", CacheRefreshInterval: - wpx.populateEnrCache() +proc getEnrsFromStore( + wpx: WakuPeerExchange, numPeers: uint64 +): seq[enr.Record] {.gcsafe.} = + # Reservoir sampling (Algorithm R) + var i = 0 + let k = min(MaxPeersCacheSize, numPeers.int) + let enrStoreLen = wpx.peerManager.switch.peerStore[ENRBook].len + var enrs = newSeqOfCap[enr.Record](min(k, enrStoreLen)) + wpx.peerManager.switch.peerStore.forEnrPeers( + peerId, peerConnectedness, peerOrigin, peerEnrRecord + ): + if peerConnectedness == CannotConnect: + debug "Could not retrieve ENR because cannot connect to peer", + remotePeerId = peerId + continue + poolFilter(wpx.cluster, peerOrigin, peerEnrRecord).isOkOr: + debug "Could not get ENR because no peer matched pool", error = error + continue + if i < k: + enrs.add(peerEnrRecord) + else: + # Add some randomness + let j = rand(i) + if j < k: + enrs[j] = peerEnrRecord + inc(i) + return enrs proc initProtocolHandler(wpx: WakuPeerExchange) = proc handler(conn: Connection, proto: string) {.async: (raises: [CancelledError]).} = @@ -174,7 +160,8 @@ proc initProtocolHandler(wpx: WakuPeerExchange) = error "Failed to respond with BAD_REQUEST:", error = $error return - let enrs = wpx.getEnrsFromCache(decBuf.request.numPeers) + let enrs = wpx.getEnrsFromStore(decBuf.request.numPeers) + info "peer exchange request received" trace "px enrs to respond", enrs = $enrs try: @@ -214,5 +201,4 @@ proc new*( ) wpx.initProtocolHandler() setServiceLimitMetric(WakuPeerExchangeCodec, rateLimitSetting) - asyncSpawn wpx.updatePxEnrCache() return wpx From 12952d070f10fba51afbbcfbfa1b782d0d2fed3a Mon Sep 17 00:00:00 2001 From: Sergei Tikhomirov Date: Tue, 9 Dec 2025 10:45:06 +0100 Subject: [PATCH 18/18] Add text file for coding LLMs with high-level nwaku info and style guide advice (#3624) * add CLAUDE.md first version * extract style guide advice * use AGENTS.md instead of CLAUDE.md for neutrality * chore: update AGENTS.md w.r.t. master developments * Apply suggestions from code review Co-authored-by: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> * remove project tree from AGENTS.md; minor editx * Apply suggestions from code review Co-authored-by: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> --------- Co-authored-by: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Co-authored-by: NagyZoltanPeter <113987313+NagyZoltanPeter@users.noreply.github.com> --- AGENTS.md | 509 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4f735f240 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,509 @@ +# AGENTS.md - AI Coding Context + +This file provides essential context for LLMs assisting with Logos Messaging development. + +## Project Identity + +Logos Messaging is designed as a shared public network for generalized messaging, not application-specific infrastructure. + +This project is a Nim implementation of a libp2p protocol suite for private, censorship-resistant P2P messaging. It targets resource-restricted devices and privacy-preserving communication. + +Logos Messaging was formerly known as Waku. Waku-related terminology remains within the codebase for historical reasons. + +### Design Philosophy + +Key architectural decisions: + +Resource-restricted first: Protocols differentiate between full nodes (relay) and light clients (filter, lightpush, store). Light clients can participate without maintaining full message history or relay capabilities. This explains the client/server split in protocol implementations. + +Privacy through unlinkability: RLN (Rate Limiting Nullifier) provides DoS protection while preserving sender anonymity. Messages are routed through pubsub topics with automatic sharding across 8 shards. Code prioritizes metadata privacy alongside content encryption. + +Scalability via sharding: The network uses automatic content-topic-based sharding to distribute traffic. This is why you'll see sharding logic throughout the codebase and why pubsub topic selection is protocol-level, not application-level. + +See [documentation](https://docs.waku.org/learn/) for architectural details. + +### Core Protocols +- Relay: Pub/sub message routing using GossipSub +- Store: Historical message retrieval and persistence +- Filter: Lightweight message filtering for resource-restricted clients +- Lightpush: Lightweight message publishing for clients +- Peer Exchange: Peer discovery mechanism +- RLN Relay: Rate limiting nullifier for spam protection +- Metadata: Cluster and shard metadata exchange between peers +- Mix: Mixnet protocol for enhanced privacy through onion routing +- Rendezvous: Alternative peer discovery mechanism + +### Key Terminology +- ENR (Ethereum Node Record): Node identity and capability advertisement +- Multiaddr: libp2p addressing format (e.g., `/ip4/127.0.0.1/tcp/60000/p2p/16Uiu2...`) +- PubsubTopic: Gossipsub topic for message routing (e.g., `/waku/2/default-waku/proto`) +- ContentTopic: Application-level message categorization (e.g., `/my-app/1/chat/proto`) +- Sharding: Partitioning network traffic across topics (static or auto-sharding) +- RLN (Rate Limiting Nullifier): Zero-knowledge proof system for spam prevention + +### Specifications +All specs are at [rfc.vac.dev/waku](https://rfc.vac.dev/waku). RFCs use `WAKU2-XXX` format (not legacy `WAKU-XXX`). + +## Architecture + +### Protocol Module Pattern +Each protocol typically follows this structure: +``` +waku_/ +├── protocol.nim # Main protocol type and handler logic +├── client.nim # Client-side API +├── rpc.nim # RPC message types +├── rpc_codec.nim # Protobuf encoding/decoding +├── common.nim # Shared types and constants +└── protocol_metrics.nim # Prometheus metrics +``` + +### WakuNode Architecture +- WakuNode (`waku/node/waku_node.nim`) is the central orchestrator +- Protocols are "mounted" onto the node's switch (libp2p component) +- PeerManager handles peer selection and connection management +- Switch provides libp2p transport, security, and multiplexing + +Example protocol type definition: +```nim +type WakuFilter* = ref object of LPProtocol + subscriptions*: FilterSubscriptions + peerManager: PeerManager + messageCache: TimedCache[string] +``` + +## Development Essentials + +### Build Requirements +- Nim 2.x (check `waku.nimble` for minimum version) +- Rust toolchain (required for RLN dependencies) +- Build system: Make with nimbus-build-system + +### Build System +The project uses Makefile with nimbus-build-system (Status's Nim build framework): +```bash +# Initial build (updates submodules) +make wakunode2 + +# After git pull, update submodules +make update + +# Build with custom flags +make wakunode2 NIMFLAGS="-d:chronicles_log_level=DEBUG" +``` + +Note: The build system uses `--mm:refc` memory management (automatically enforced). Only relevant if compiling outside the standard build system. + +### Common Make Targets +```bash +make wakunode2 # Build main node binary +make test # Run all tests +make testcommon # Run common tests only +make libwakuStatic # Build static C library +make chat2 # Build chat example +make install-nph # Install git hook for auto-formatting +``` + +### Testing +```bash +# Run all tests +make test + +# Run specific test file +make test tests/test_waku_enr.nim + +# Run specific test case from file +make test tests/test_waku_enr.nim "check capabilities support" + +# Build and run test separately (for development iteration) +make test tests/test_waku_enr.nim +``` + +Test structure uses `testutils/unittests`: +```nim +import testutils/unittests + +suite "Waku ENR - Capabilities": + test "check capabilities support": + ## Given + let bitfield: CapabilitiesBitfield = 0b0000_1101u8 + + ## Then + check: + bitfield.supportsCapability(Capabilities.Relay) + not bitfield.supportsCapability(Capabilities.Store) +``` + +### Code Formatting +Mandatory: All code must be formatted with `nph` (vendored in `vendor/nph`) +```bash +# Format specific file +make nph/waku/waku_core.nim + +# Install git pre-commit hook (auto-formats on commit) +make install-nph +``` +The nph formatter handles all formatting details automatically, especially with the pre-commit hook installed. Focus on semantic correctness. + +### Logging +Uses `chronicles` library with compile-time configuration: +```nim +import chronicles + +logScope: + topics = "waku lightpush" + +info "handling request", peerId = peerId, topic = pubsubTopic +error "request failed", error = msg +``` + +Compile with log level: +```bash +nim c -d:chronicles_log_level=TRACE myfile.nim +``` + + +## Code Conventions + +Common pitfalls: +- Always handle Result types explicitly +- Avoid global mutable state: Pass state through parameters +- Keep functions focused: Under 50 lines when possible +- Prefer compile-time checks (`static assert`) over runtime checks + +### Naming +- Files/Directories: `snake_case` (e.g., `waku_lightpush`, `peer_manager`) +- Procedures: `camelCase` (e.g., `handleRequest`, `pushMessage`) +- Types: `PascalCase` (e.g., `WakuFilter`, `PubsubTopic`) +- Constants: `PascalCase` (e.g., `MaxContentTopicsPerRequest`) +- Constructors: `func init(T: type Xxx, params): T` +- For ref types: `func new(T: type Xxx, params): ref T` +- Exceptions: `XxxError` for CatchableError, `XxxDefect` for Defect +- ref object types: `XxxRef` suffix + +### Imports Organization +Group imports: stdlib, external libs, internal modules: +```nim +import + std/[options, sequtils], # stdlib + results, chronicles, chronos, # external + libp2p/peerid +import + ../node/peer_manager, # internal (separate import block) + ../waku_core, + ./common +``` + +### Async Programming +Uses chronos, not stdlib `asyncdispatch`: +```nim +proc handleRequest( + wl: WakuLightPush, peerId: PeerId +): Future[WakuLightPushResult] {.async.} = + let res = await wl.pushHandler(peerId, pubsubTopic, message) + return res +``` + +### Error Handling +The project uses both Result types and exceptions: + +Result types from nim-results are used for protocol and API-level errors: +```nim +proc subscribe( + wf: WakuFilter, peerId: PeerID +): Future[FilterSubscribeResult] {.async.} = + if contentTopics.len > MaxContentTopicsPerRequest: + return err(FilterSubscribeError.badRequest("exceeds maximum")) + + # Handle Result with isOkOr + (await wf.subscriptions.addSubscription(peerId, criteria)).isOkOr: + return err(FilterSubscribeError.serviceUnavailable(error)) + + ok() +``` + +Exceptions still used for: +- chronos async failures (CancelledError, etc.) +- Database/system errors +- Library interop + +Most files start with `{.push raises: [].}` to disable exception tracking, then use try/catch blocks where needed. + +### Pragma Usage +```nim +{.push raises: [].} # Disable default exception tracking (at file top) + +proc myProc(): Result[T, E] {.async.} = # Async proc +``` + +### Protocol Inheritance +Protocols inherit from libp2p's `LPProtocol`: +```nim +type WakuLightPush* = ref object of LPProtocol + rng*: ref rand.HmacDrbgContext + peerManager*: PeerManager + pushHandler*: PushMessageHandler +``` + +### Type Visibility +- Public exports use `*` suffix: `type WakuFilter* = ...` +- Fields without `*` are module-private + +## Style Guide Essentials + +This section summarizes key Nim style guidelines relevant to this project. Full guide: https://status-im.github.io/nim-style-guide/ + +### Language Features + +Import and Export +- Use explicit import paths with std/ prefix for stdlib +- Group imports: stdlib, external, internal (separate blocks) +- Export modules whose types appear in public API +- Avoid include + +Macros and Templates +- Avoid macros and templates - prefer simple constructs +- Avoid generating public API with macros +- Put logic in templates, use macros only for glue code + +Object Construction +- Prefer Type(field: value) syntax +- Use Type.init(params) convention for constructors +- Default zero-initialization should be valid state +- Avoid using result variable for construction + +ref object Types +- Avoid ref object unless needed for: + - Resource handles requiring reference semantics + - Shared ownership + - Reference-based data structures (trees, lists) + - Stable pointer for FFI +- Use explicit ref MyType where possible +- Name ref object types with Ref suffix: XxxRef + +Memory Management +- Prefer stack-based and statically sized types in core code +- Use heap allocation in glue layers +- Avoid alloca +- For FFI: use create/dealloc or createShared/deallocShared + +Variable Usage +- Use most restrictive of const, let, var (prefer const over let over var) +- Prefer expressions for initialization over var then assignment +- Avoid result variable - use explicit return or expression-based returns + +Functions +- Prefer func over proc +- Avoid public (*) symbols not part of intended API +- Prefer openArray over seq for function parameters + +Methods (runtime polymorphism) +- Avoid method keyword for dynamic dispatch +- Prefer manual vtable with proc closures for polymorphism +- Methods lack support for generics + +Miscellaneous +- Annotate callback proc types with {.raises: [], gcsafe.} +- Avoid explicit {.inline.} pragma +- Avoid converters +- Avoid finalizers + +Type Guidelines + +Binary Data +- Use byte for binary data +- Use seq[byte] for dynamic arrays +- Convert string to seq[byte] early if stdlib returns binary as string + +Integers +- Prefer signed (int, int64) for counting, lengths, indexing +- Use unsigned with explicit size (uint8, uint64) for binary data, bit ops +- Avoid Natural +- Check ranges before converting to int +- Avoid casting pointers to int +- Avoid range types + +Strings +- Use string for text +- Use seq[byte] for binary data instead of string + +### Error Handling + +Philosophy +- Prefer Result, Opt for explicit error handling +- Use Exceptions only for legacy code compatibility + +Result Types +- Use Result[T, E] for operations that can fail +- Use cstring for simple error messages: Result[T, cstring] +- Use enum for errors needing differentiation: Result[T, SomeErrorEnum] +- Use Opt[T] for simple optional values +- Annotate all modules: {.push raises: [].} at top + +Exceptions (when unavoidable) +- Inherit from CatchableError, name XxxError +- Use Defect for panics/logic errors, name XxxDefect +- Annotate functions explicitly: {.raises: [SpecificError].} +- Catch specific error types, avoid catching CatchableError +- Use expression-based try blocks +- Isolate legacy exception code with try/except, convert to Result + +Common Defect Sources +- Overflow in signed arithmetic +- Array/seq indexing with [] +- Implicit range type conversions + +Status Codes +- Avoid status code pattern +- Use Result instead + +### Library Usage + +Standard Library +- Use judiciously, prefer focused packages +- Prefer these replacements: + - async: chronos + - bitops: stew/bitops2 + - endians: stew/endians2 + - exceptions: results + - io: stew/io2 + +Results Library +- Use cstring errors for diagnostics without differentiation +- Use enum errors when caller needs to act on specific errors +- Use complex types when additional error context needed +- Use isOkOr pattern for chaining + +Wrappers (C/FFI) +- Prefer native Nim when available +- For C libraries: use {.compile.} to build from source +- Create xxx_abi.nim for raw ABI wrapper +- Avoid C++ libraries + +Miscellaneous +- Print hex output in lowercase, accept both cases + +### Common Pitfalls + +- Defects lack tracking by {.raises.} +- nil ref causes runtime crashes +- result variable disables branch checking +- Exception hierarchy unclear between Nim versions +- Range types have compiler bugs +- Finalizers infect all instances of type + +## Common Workflows + +### Adding a New Protocol +1. Create directory: `waku/waku_myprotocol/` +2. Define core files: + - `rpc.nim` - Message types + - `rpc_codec.nim` - Protobuf encoding + - `protocol.nim` - Protocol handler + - `client.nim` - Client API + - `common.nim` - Shared types +3. Define protocol type in `protocol.nim`: + ```nim + type WakuMyProtocol* = ref object of LPProtocol + peerManager: PeerManager + # ... fields + ``` +4. Implement request handler +5. Mount in WakuNode (`waku/node/waku_node.nim`) +6. Add tests in `tests/waku_myprotocol/` +7. Export module via `waku/waku_myprotocol.nim` + +### Adding a REST API Endpoint +1. Define handler in `waku/rest_api/endpoint/myprotocol/` +2. Implement endpoint following pattern: + ```nim + proc installMyProtocolApiHandlers*( + router: var RestRouter, node: WakuNode + ) = + router.api(MethodGet, "/waku/v2/myprotocol/endpoint") do () -> RestApiResponse: + # Implementation + return RestApiResponse.jsonResponse(data, status = Http200) + ``` +3. Register in `waku/rest_api/handlers.nim` + +### Adding Database Migration +For message_store (SQLite): +1. Create `migrations/message_store/NNNNN_description.up.sql` +2. Create corresponding `.down.sql` for rollback +3. Increment version number sequentially +4. Test migration locally before committing + +For PostgreSQL: add in `migrations/message_store_postgres/` + +### Running Single Test During Development +```bash +# Build test binary +make test tests/waku_filter_v2/test_waku_client.nim + +# Binary location +./build/tests/waku_filter_v2/test_waku_client.nim.bin + +# Or combine +make test tests/waku_filter_v2/test_waku_client.nim "specific test name" +``` + +### Debugging with Chronicles +Set log level and filter topics: +```bash +nim c -r \ + -d:chronicles_log_level=TRACE \ + -d:chronicles_disabled_topics="eth,dnsdisc" \ + tests/mytest.nim +``` + +## Key Constraints + +### Vendor Directory +- Never edit files directly in vendor - it is auto-generated from git submodules +- Always run `make update` after pulling changes +- Managed by `nimbus-build-system` + +### Chronicles Performance +- Log levels are configured at compile time for performance +- Runtime filtering is available but should be used sparingly: `-d:chronicles_runtime_filtering=on` +- Default sinks are optimized for production + +### Memory Management +- Uses `refc` (reference counting with cycle collection) +- Automatically enforced by the build system (hardcoded in `waku.nimble`) +- Do not override unless absolutely necessary, as it breaks compatibility + +### RLN Dependencies +- RLN code requires a Rust toolchain, which explains Rust imports in some modules +- Pre-built `librln` libraries are checked into the repository + +## Quick Reference + +Language: Nim 2.x | License: MIT or Apache 2.0 + +### Important Files +- `Makefile` - Primary build interface +- `waku.nimble` - Package definition and build tasks (called via nimbus-build-system) +- `vendor/nimbus-build-system/` - Status's build framework +- `waku/node/waku_node.nim` - Core node implementation +- `apps/wakunode2/wakunode2.nim` - Main CLI application +- `waku/factory/waku_conf.nim` - Configuration types +- `library/libwaku.nim` - C bindings entry point + +### Testing Entry Points +- `tests/all_tests_waku.nim` - All Waku protocol tests +- `tests/all_tests_wakunode2.nim` - Node application tests +- `tests/all_tests_common.nim` - Common utilities tests + +### Key Dependencies +- `chronos` - Async framework +- `nim-results` - Result type for error handling +- `chronicles` - Logging +- `libp2p` - P2P networking +- `confutils` - CLI argument parsing +- `presto` - REST server +- `nimcrypto` - Cryptographic primitives + +Note: For specific version requirements, check `waku.nimble`. + +