Merge branch 'master' of github.com:waku-org/js-waku into weboko/react

This commit is contained in:
Sasha 2025-10-08 00:53:08 +02:00
commit bc5fbef9f0
No known key found for this signature in database
95 changed files with 4357 additions and 8478 deletions

View File

@ -24,9 +24,11 @@
"cipherparams",
"ciphertext",
"circleci",
"circom",
"codecov",
"codegen",
"commitlint",
"cooldown",
"dependabot",
"dialable",
"dingpu",
@ -41,9 +43,7 @@
"Encrypters",
"enr",
"enrs",
"unsubscription",
"enrtree",
"unhandle",
"ephem",
"esnext",
"ethersproject",
@ -62,7 +62,6 @@
"ineed",
"IPAM",
"ipfs",
"cooldown",
"iwant",
"jdev",
"jswaku",
@ -122,9 +121,11 @@
"typedoc",
"undialable",
"unencrypted",
"unhandle",
"unmarshal",
"unmount",
"unmounts",
"unsubscription",
"untracked",
"upgrader",
"vacp",
@ -139,6 +140,7 @@
"weboko",
"websockets",
"wifi",
"WTNS",
"xsalsa20",
"zerokit",
"Привет",

View File

@ -8,9 +8,6 @@ on:
env:
NODE_JS: "22"
EXAMPLE_TEMPLATE: "web-chat"
EXAMPLE_NAME: "example"
EXAMPLE_PORT: "8080"
# Firefox in container fails due to $HOME not being owned by user running commands
# more details https://github.com/microsoft/playwright/issues/6500
HOME: "/root"
@ -29,11 +26,8 @@ jobs:
- uses: ./.github/actions/npm
- name: Build browser container
run: npm run build --workspace=@waku/headless-tests
- name: Build browser test environment
run: npm run build --workspace=@waku/browser-tests
- name: Build entire monorepo
run: npm run build
- name: Run Playwright tests
run: npm run test --workspace=@waku/browser-tests

View File

@ -1,45 +0,0 @@
FROM node:20-slim
# Install Chrome dependencies
RUN apt-get update && apt-get install -y \
procps \
libglib2.0-0 \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libcairo2 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY packages/browser-tests/package.json ./packages/browser-tests/
COPY packages/headless-tests/package.json ./packages/headless-tests/
# Install dependencies and serve
RUN npm install && npm install -g serve
# Copy source files
COPY tsconfig.json ./
COPY packages/ ./packages/
# Build packages
RUN npm run build -w packages/headless-tests && \
npm run build:server -w packages/browser-tests && \
npx playwright install chromium
EXPOSE 3000
CMD ["npm", "run", "start:server", "-w", "packages/browser-tests"]

4623
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@
"packages/relay",
"packages/tests",
"packages/reliability-tests",
"packages/headless-tests",
"packages/browser-tests",
"packages/build-utils",
"packages/react"

View File

@ -1,5 +1,4 @@
node_modules
dist
build
.DS_Store
*.log

View File

@ -12,7 +12,7 @@ module.exports = {
plugins: ["import"],
extends: ["eslint:recommended"],
rules: {
"no-console": "off"
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "ignoreRestSiblings": true }]
},
globals: {
process: true

View File

@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1
# Build stage - install all dependencies and build
FROM node:22-bullseye AS builder
WORKDIR /app
# Copy package.json and temporarily remove workspace dependencies that can't be resolved
COPY package.json package.json.orig
RUN sed '/"@waku\/tests": "\*",/d' package.json.orig > package.json
RUN npm install --no-audit --no-fund
COPY src ./src
COPY types ./types
COPY tsconfig.json ./
COPY web ./web
RUN npm run build
# Production stage - only runtime dependencies
FROM node:22-bullseye
# Install required system deps for Playwright Chromium
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
fonts-liberation \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy package files and install only production dependencies
COPY package.json package.json.orig
RUN sed '/"@waku\/tests": "\*",/d' package.json.orig > package.json
RUN npm install --only=production --no-audit --no-fund
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
# Install Playwright browsers (Chromium only) at runtime layer
RUN npx playwright install --with-deps chromium
ENV PORT=8080 \
NODE_ENV=production
EXPOSE 8080
# Use a script to handle CLI arguments and environment variables
COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["npm", "run", "start:server"]

View File

@ -1,182 +1,174 @@
# Waku Browser Tests
This project provides a system for testing the Waku SDK in a browser environment.
This package provides a containerized Waku light node simulation server for testing and development. The server runs a headless browser using Playwright and exposes a REST API similar to the nwaku REST API. A Dockerfile is provided to allow programmatic simulation and "deployment" of js-waku nodes in any Waku orchestration environment that uses Docker (e.g. [10ksim](https://github.com/vacp2p/10ksim) ).
## Architecture
## Quick Start
The system consists of:
1. **Headless Web App**: A simple web application (in the `@waku/headless-tests` package) that loads the Waku SDK and exposes shared API functions.
2. **Express Server**: A server that communicates with the headless app using Playwright.
3. **Shared API**: TypeScript functions shared between the server and web app.
## Setup
1. Install dependencies:
```bash
# Install main dependencies
npm install
# Install headless app dependencies
cd ../headless-tests
npm install
cd ../browser-tests
```
2. Build the application:
### Build and Run
```bash
# Build the application
npm run build
# Start the server (port 8080)
npm run start:server
# Build and run Docker container
npm run docker:build
docker run -p 8080:8080 waku-browser-tests:local
```
This will:
- Build the headless web app using webpack
- Compile the TypeScript server code
## Configuration
## Running
Configure the Waku node using environment variables:
Start the server with:
### Network Configuration
- `WAKU_CLUSTER_ID`: Cluster ID (default: 1)
- `WAKU_SHARD`: Specific shard number - enables static sharding mode (optional)
**Sharding Behavior:**
- **Auto-sharding** (default): Uses `numShardsInCluster: 8` across cluster 1
- **Static sharding**: When `WAKU_SHARD` is set, uses only that specific shard
### Bootstrap Configuration
- `WAKU_ENR_BOOTSTRAP`: Enable ENR bootstrap mode with custom bootstrap peers (comma-separated)
- `WAKU_LIGHTPUSH_NODE`: Preferred lightpush node multiaddr (Docker only)
### ENR Bootstrap Mode
When `WAKU_ENR_BOOTSTRAP` is set:
- Disables default bootstrap (`defaultBootstrap: false`)
- Enables DNS discovery using production ENR trees
- Enables peer exchange and peer cache
- Uses the specified ENR for additional bootstrap peers
```bash
npm run start:server
# Example: ENR bootstrap mode
WAKU_ENR_BOOTSTRAP="enr:-QEnuEBEAyErHEfhiQxAVQoWowGTCuEF9fKZtXSd7H_PymHFhGJA3rGAYDVSHKCyJDGRLBGsloNbS8AZF33IVuefjOO6BIJpZIJ2NIJpcIQS39tkim11bHRpYWRkcnO4lgAvNihub2RlLTAxLmRvLWFtczMud2FrdXYyLnRlc3Quc3RhdHVzaW0ubmV0BgG73gMAODcxbm9kZS0wMS5hYy1jbi1ob25na29uZy1jLndha3V2Mi50ZXN0LnN0YXR1c2ltLm5ldAYBu94DACm9A62t7AQL4Ef5ZYZosRpQTzFVAB8jGjf1TER2wH-0zBOe1-MDBNLeA4lzZWNwMjU2azGhAzfsxbxyCkgCqq8WwYsVWH7YkpMLnU2Bw5xJSimxKav-g3VkcIIjKA" npm run start:server
```
This will:
1. Serve the headless app on port 8080
2. Start a headless browser to load the app
3. Expose API endpoints to interact with Waku
## API Endpoints
- `GET /info`: Get information about the Waku node
- `GET /debug/v1/info`: Get debug information from the Waku node
- `POST /push`: Push a message to the Waku network (legacy)
- `POST /lightpush/v1/message`: Push a message to the Waku network (Waku REST API compatible)
- `POST /admin/v1/create-node`: Create a new Waku node (requires networkConfig)
- `POST /admin/v1/start-node`: Start the Waku node
- `POST /admin/v1/stop-node`: Stop the Waku node
- `POST /admin/v1/peers`: Dial to specified peers (Waku REST API compatible)
- `GET /filter/v2/messages/:contentTopic`: Subscribe to messages on a specific content topic using Server-Sent Events (Waku REST API compatible)
- `GET /filter/v1/messages/:contentTopic`: Retrieve stored messages from a content topic (Waku REST API compatible)
The server exposes the following HTTP endpoints:
### Example: Pushing a message with the legacy endpoint
### Node Management
- `GET /`: Health check - returns server status
- `GET /waku/v1/peer-info`: Get node peer information
- `POST /waku/v1/wait-for-peers`: Wait for peers with specific protocols
### Messaging
- `POST /lightpush/v3/message`: Send message via lightpush
### Static Files
- `GET /app/index.html`: Web application entry point
- `GET /app/*`: Static web application files
### Examples
#### Send a Message (Auto-sharding)
```bash
curl -X POST http://localhost:3000/push \
-H "Content-Type: application/json" \
-d '{"contentTopic": "/toy-chat/2/huilong/proto", "payload": [1, 2, 3]}'
```
### Example: Pushing a message with the Waku REST API compatible endpoint
```bash
curl -X POST http://localhost:3000/lightpush/v1/message \
curl -X POST http://localhost:8080/lightpush/v3/message \
-H "Content-Type: application/json" \
-d '{
"pubsubTopic": "/waku/2/rs/0/0",
"pubsubTopic": "",
"message": {
"payload": "SGVsbG8sIFdha3Uh",
"contentTopic": "/toy-chat/2/huilong/proto",
"timestamp": 1712135330213797632
"contentTopic": "/test/1/example/proto",
"payload": "SGVsbG8gV2FrdQ==",
"version": 1
}
}'
```
### Example: Executing a function
#### Send a Message (Explicit pubsub topic)
```bash
curl -X POST http://localhost:3000/execute \
-H "Content-Type: application/json" \
-d '{"functionName": "getPeerInfo", "params": []}'
```
### Example: Creating a Waku node
```bash
curl -X POST http://localhost:3000/admin/v1/create-node \
curl -X POST http://localhost:8080/lightpush/v3/message \
-H "Content-Type: application/json" \
-d '{
"defaultBootstrap": true,
"networkConfig": {
"clusterId": 1,
"shards": [0, 1]
"pubsubTopic": "/waku/2/rs/1/4",
"message": {
"contentTopic": "/test/1/example/proto",
"payload": "SGVsbG8gV2FrdQ==",
"version": 1
}
}'
```
### Example: Starting and stopping a Waku node
#### Wait for Peers
```bash
# Start the node
curl -X POST http://localhost:3000/admin/v1/start-node
# Stop the node
curl -X POST http://localhost:3000/admin/v1/stop-node
```
### Example: Dialing to specific peers with the Waku REST API compatible endpoint
```bash
curl -X POST http://localhost:3000/admin/v1/peers \
curl -X POST http://localhost:8080/waku/v1/wait-for-peers \
-H "Content-Type: application/json" \
-d '{
"peerMultiaddrs": [
"/ip4/127.0.0.1/tcp/8000/p2p/16Uiu2HAm4v8KuHUH6Cwz3upPeQbkyxQJsFGPdt7kHtkN8F79QiE6"]
]
"timeoutMs": 30000,
"protocols": ["lightpush", "filter"]
}'
```
### Example: Dialing to specific peers with the execute endpoint
#### Get Peer Info
```bash
curl -X POST http://localhost:3000/execute \
-H "Content-Type: application/json" \
-d '{
"functionName": "dialPeers",
"params": [
["/ip4/127.0.0.1/tcp/8000/p2p/16Uiu2HAm4v8KuHUH6Cwz3upPeQbkyxQJsFGPdt7kHtkN8F79QiE6"]
]
}'
curl -X GET http://localhost:8080/waku/v1/peer-info
```
### Example: Subscribing to a content topic with the filter endpoint
## CLI Usage
Run with CLI arguments:
```bash
# Open a persistent connection to receive messages as Server-Sent Events
curl -N http://localhost:3000/filter/v2/messages/%2Ftoy-chat%2F2%2Fhuilong%2Fproto
# You can also specify clustering options
curl -N "http://localhost:3000/filter/v2/messages/%2Ftoy-chat%2F2%2Fhuilong%2Fproto?clusterId=0&shard=0"
# Custom cluster and shard
node dist/src/server.js --cluster-id=2 --shard=0
```
### Example: Retrieving stored messages from a content topic
## Testing
The package includes several test suites:
```bash
# Get the most recent 20 messages
curl http://localhost:3000/filter/v1/messages/%2Ftoy-chat%2F2%2Fhuilong%2Fproto
# Basic server functionality tests (default)
npm test
# Get messages with pagination and time filtering
curl "http://localhost:3000/filter/v1/messages/%2Ftoy-chat%2F2%2Fhuilong%2Fproto?pageSize=10&startTime=1712000000000&endTime=1713000000000&ascending=true"
# Docker testing workflow
npm run docker:build
npm run test:integration
# All tests
npm run test:all
# Individual test suites:
npm run test:server # Server-only tests
npm run test:e2e # End-to-end tests
```
## Extending
**Test Types:**
- `server.spec.ts` - Tests basic server functionality and static file serving
- `integration.spec.ts` - Tests Docker container integration with external services
- `e2e.spec.ts` - Full end-to-end tests using nwaku nodes
To add new functionality:
## Docker Usage
1. Add your function to `src/api/shared.ts`
2. Add your function to the `API` object in `src/api/shared.ts`
3. Use it via the server endpoints
### Example: Dialing to specific peers
The package includes Docker support for containerized testing:
```bash
curl -X POST http://localhost:3000/execute \
-H "Content-Type: application/json" \
-d '{
"functionName": "dialPeers",
"params": [
["/ip4/127.0.0.1/tcp/8000/p2p/16Uiu2HAm4v8KuHUH6Cwz3upPeQbkyxQJsFGPdt7kHtkN8F79QiE6"]
]
}'
# Build image
docker build -t waku-browser-tests:local .
# Run with ENR bootstrap
docker run -p 8080:8080 \
-e WAKU_ENR_BOOTSTRAP="enr:-QEnuE..." \
-e WAKU_CLUSTER_ID="1" \
waku-browser-tests:local
# Run with specific configuration
docker run -p 8080:8080 \
-e WAKU_CLUSTER_ID="2" \
-e WAKU_SHARD="0" \
waku-browser-tests:local
```
## Development
The server automatically:
- Creates a Waku light node on startup
- Configures network settings from environment variables
- Enables appropriate protocols (lightpush, filter)
- Handles peer discovery and connection management
All endpoints are CORS-enabled for cross-origin requests.

View File

@ -5,27 +5,38 @@
"type": "module",
"scripts": {
"start": "npm run start:server",
"start:server": "node ./dist/server.js",
"test": "npx playwright test",
"start:server": "PORT=8080 node ./dist/src/server.js",
"test": "npx playwright test tests/server.spec.ts --reporter=line",
"test:all": "npx playwright test --reporter=line",
"test:server": "npx playwright test tests/server.spec.ts --reporter=line",
"test:integration": "npx playwright test tests/integration.spec.ts --reporter=line",
"test:e2e": "npx playwright test tests/e2e.spec.ts --reporter=line",
"build:server": "tsc -p tsconfig.json",
"build": "npm run build:server"
"build:web": "esbuild web/index.ts --bundle --format=esm --platform=browser --outdir=dist/web && cp web/index.html dist/web/index.html",
"build": "npm-run-all -s build:server build:web",
"docker:build": "docker build -t waku-browser-tests:local . && docker tag waku-browser-tests:local waku-browser-tests:latest"
},
"dependencies": {
"@playwright/test": "^1.51.1",
"@waku/discovery": "^0.0.11",
"@waku/interfaces": "^0.0.33",
"@waku/sdk": "^0.0.34",
"@waku/utils": "0.0.27",
"cors": "^2.8.5",
"dotenv-flow": "^0.4.0",
"express": "^4.21.2",
"filter-obj": "^2.0.2",
"it-first": "^3.0.9"
},
"devDependencies": {
"@types/cors": "^2.8.15",
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@waku/tests": "*",
"axios": "^1.8.4",
"dotenv-flow": "^0.4.0",
"esbuild": "^0.21.5",
"npm-run-all": "^4.1.5",
"serve": "^14.2.3",
"typescript": "5.8.3",
"webpack-cli": "^6.0.1"
},
"dependencies": {
"@playwright/test": "^1.51.1",
"@waku/sdk": "^0.0.30",
"cors": "^2.8.5",
"express": "^4.21.2",
"node-polyfill-webpack-plugin": "^4.1.0"
"testcontainers": "^10.9.0",
"typescript": "5.8.3"
}
}

View File

@ -1,57 +1,39 @@
// For dynamic import of dotenv-flow
import { defineConfig, devices } from "@playwright/test";
import { Logger } from "@waku/utils";
const log = new Logger("playwright-config");
// Only load dotenv-flow in non-CI environments
if (!process.env.CI) {
// Need to use .js extension for ES modules
// eslint-disable-next-line import/extensions
await import("dotenv-flow/config.js");
try {
await import("dotenv-flow/config.js");
} catch (e) {
log.warn("dotenv-flow not found; skipping env loading");
}
}
const EXAMPLE_PORT = process.env.EXAMPLE_PORT || "8080";
// web-chat specific thingy
const EXAMPLE_TEMPLATE = process.env.EXAMPLE_TEMPLATE || "";
const BASE_URL = `http://127.0.0.1:${EXAMPLE_PORT}/${EXAMPLE_TEMPLATE}`;
const BASE_URL = `http://127.0.0.1:${EXAMPLE_PORT}`;
const TEST_IGNORE = process.env.CI ? ["tests/e2e.spec.ts"] : [];
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
testIgnore: TEST_IGNORE,
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 2 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: BASE_URL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry"
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] }
}
],
]
/* Run your local dev server before starting the tests */
webServer: {
url: BASE_URL,
stdout: "pipe",
stderr: "pipe",
command: "npm run start:server",
reuseExistingServer: !process.env.CI,
timeout: 5 * 60 * 1000 // five minutes for bootstrapping an example
}
});

View File

@ -0,0 +1,54 @@
#!/bin/bash
# Docker entrypoint script for waku-browser-tests
# Handles CLI arguments and converts them to environment variables
# Supports reading discovered addresses from /etc/addrs/addrs.env (10k sim pattern)
echo "docker-entrypoint.sh"
echo "Using address: $addrs1"
# Only set WAKU_LIGHTPUSH_NODE if it's not already set and addrs1 is available
if [ -z "$WAKU_LIGHTPUSH_NODE" ] && [ -n "$addrs1" ]; then
export WAKU_LIGHTPUSH_NODE="$addrs1"
fi
echo "Num Args: $#"
echo "Args: $@"
echo "WAKU_LIGHTPUSH_NODE=$WAKU_LIGHTPUSH_NODE"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--cluster-id=*)
export WAKU_CLUSTER_ID="${1#*=}"
echo "Setting WAKU_CLUSTER_ID=${WAKU_CLUSTER_ID}"
shift
;;
--shard=*)
export WAKU_SHARD="${1#*=}"
echo "Setting WAKU_SHARD=${WAKU_SHARD}"
shift
;;
--lightpushnode=*)
export WAKU_LIGHTPUSH_NODE="${1#*=}"
echo "Setting WAKU_LIGHTPUSH_NODE=${WAKU_LIGHTPUSH_NODE}"
shift
;;
--enr-bootstrap=*)
export WAKU_ENR_BOOTSTRAP="${1#*=}"
echo "Setting WAKU_ENR_BOOTSTRAP=${WAKU_ENR_BOOTSTRAP}"
shift
;;
*)
# Unknown argument, notify user and keep it for the main command
echo "Warning: Unknown argument '$1' will be passed to the main command"
break
;;
esac
done
# If no specific command is provided, use the default CMD
if [ $# -eq 0 ]; then
set -- "npm" "run" "start:server"
fi
# Execute the main command
exec "$@"

View File

@ -1,22 +0,0 @@
/**
* Shared utilities for working with Waku nodes
* This file contains functions used by both browser tests and server
*/
/**
* Type definition for a minimal Waku node interface
* This allows us to use the same code in different contexts
*/
export interface IWakuNode {
libp2p: {
peerId: { toString(): string };
getMultiaddrs(): Array<{ toString(): string }>;
getProtocols(): any;
peerStore: {
all(): Promise<Array<{ id: { toString(): string } }>>;
};
};
lightPush: {
send: (encoder: any, message: { payload: Uint8Array }) => Promise<{ successes: any[] }>;
};
}

View File

@ -1,36 +0,0 @@
import { IWakuNode } from "./common.js";
/**
* Gets peer information from a Waku node
* Used in both server API endpoints and headless tests
*/
export async function getPeerInfo(waku: IWakuNode): Promise<{
peerId: string;
multiaddrs: string[];
peers: string[];
}> {
const multiaddrs = waku.libp2p.getMultiaddrs();
const peers = await waku.libp2p.peerStore.all();
return {
peerId: waku.libp2p.peerId.toString(),
multiaddrs: multiaddrs.map((addr) => addr.toString()),
peers: peers.map((peer) => peer.id.toString())
};
}
/**
* Gets debug information from a Waku node
* Used in both server API endpoints and tests
*/
export async function getDebugInfo(waku: IWakuNode): Promise<{
listenAddresses: string[];
peerId: string;
protocols: string[];
}> {
return {
listenAddresses: waku.libp2p.getMultiaddrs().map((addr) => addr.toString()),
peerId: waku.libp2p.peerId.toString(),
protocols: Array.from(waku.libp2p.getProtocols())
};
}

View File

@ -1,16 +0,0 @@
import { createEncoder, LightNode, SDKProtocolResult } from "@waku/sdk";
export async function pushMessage(
waku: LightNode,
contentTopic: string,
payload?: Uint8Array
): Promise<SDKProtocolResult> {
const enc = createEncoder({
contentTopic
});
const result = await waku.lightPush.send(enc, {
payload: payload ?? new Uint8Array()
});
return result;
}

View File

@ -1,274 +0,0 @@
import {
createDecoder,
createEncoder,
createLightNode,
CreateNodeOptions,
DecodedMessage,
LightNode,
SDKProtocolResult,
SubscribeResult
} from "@waku/sdk";
import { IWakuNode } from "./common.js";
/**
* Gets peer information from a Waku node
*/
export async function getPeerInfo(waku: IWakuNode): Promise<{
peerId: string;
multiaddrs: string[];
peers: string[];
}> {
const multiaddrs = waku.libp2p.getMultiaddrs();
const peers = await waku.libp2p.peerStore.all();
return {
peerId: waku.libp2p.peerId.toString(),
multiaddrs: multiaddrs.map((addr) => addr.toString()),
peers: peers.map((peer) => peer.id.toString())
};
}
/**
* Gets debug information from a Waku node
*/
export async function getDebugInfo(waku: IWakuNode): Promise<{
listenAddresses: string[];
peerId: string;
protocols: string[];
}> {
return {
listenAddresses: waku.libp2p.getMultiaddrs().map((addr) => addr.toString()),
peerId: waku.libp2p.peerId.toString(),
protocols: Array.from(waku.libp2p.getProtocols())
};
}
/**
* Pushes a message to the network
*/
export async function pushMessage(
waku: LightNode,
contentTopic: string,
payload?: Uint8Array,
options?: {
clusterId?: number;
shard?: number;
}
): Promise<SDKProtocolResult> {
if (!waku) {
throw new Error("Waku node not found");
}
const encoder = createEncoder({
contentTopic,
pubsubTopicShardInfo: {
clusterId: options?.clusterId ?? 1,
shard: options?.shard ?? 1
}
});
const result = await waku.lightPush.send(encoder, {
payload: payload ?? new Uint8Array()
});
return result;
}
/**
* Creates and initializes a Waku node
* Checks if a node is already running in window and stops it if it exists
*/
export async function createWakuNode(
options: CreateNodeOptions
): Promise<{ success: boolean; error?: string }> {
// Check if we're in a browser environment and a node already exists
if (typeof window === "undefined") {
return { success: false, error: "No window found" };
}
try {
if ((window as any).waku) {
await (window as any).waku.stop();
}
(window as any).waku = await createLightNode(options);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function startNode(): Promise<{
success: boolean;
error?: string;
}> {
if (typeof window !== "undefined" && (window as any).waku) {
try {
await (window as any).waku.start();
return { success: true };
} catch (error: any) {
// Silently continue if there's an error starting the node
return { success: false, error: error.message };
}
}
return { success: false, error: "Waku node not found in window" };
}
export async function stopNode(): Promise<{
success: boolean;
error?: string;
}> {
if (typeof window !== "undefined" && (window as any).waku) {
await (window as any).waku.stop();
return { success: true };
}
return { success: false, error: "Waku node not found in window" };
}
export async function dialPeers(
waku: LightNode,
peers: string[]
): Promise<{
total: number;
errors: string[];
}> {
const total = peers.length;
const errors: string[] = [];
await Promise.allSettled(
peers.map((peer) =>
waku.dial(peer).catch((error: any) => {
errors.push(error.message);
})
)
);
return { total, errors };
}
export async function subscribe(
waku: LightNode,
contentTopic: string,
options?: {
clusterId?: number;
shard?: number;
},
// eslint-disable-next-line no-unused-vars
callback?: (message: DecodedMessage) => void
): Promise<SubscribeResult> {
const clusterId = options?.clusterId ?? 42;
const shard = options?.shard ?? 0;
console.log(
`Creating decoder for content topic ${contentTopic} with clusterId=${clusterId}, shard=${shard}`
);
const pubsubTopic = `/waku/2/rs/${clusterId}/${shard}`;
let configuredTopics: string[] = [];
try {
const protocols = waku.libp2p.getProtocols();
console.log(`Available protocols: ${Array.from(protocols).join(", ")}`);
const metadataMethod = (waku.libp2p as any)._services?.metadata?.getInfo;
if (metadataMethod) {
const metadata = metadataMethod();
console.log(`Node metadata: ${JSON.stringify(metadata)}`);
if (metadata?.pubsubTopics && Array.isArray(metadata.pubsubTopics)) {
configuredTopics = metadata.pubsubTopics;
console.log(
`Found configured pubsub topics: ${configuredTopics.join(", ")}`
);
}
}
if (
configuredTopics.length > 0 &&
!configuredTopics.includes(pubsubTopic)
) {
console.warn(
`Pubsub topic ${pubsubTopic} is not configured. Configured topics: ${configuredTopics.join(", ")}`
);
for (const topic of configuredTopics) {
const parts = topic.split("/");
if (parts.length === 6 && parts[1] === "waku" && parts[3] === "rs") {
console.log(`Found potential matching pubsub topic: ${topic}`);
// Use the first topic as a fallback if no exact match is found
// This isn't ideal but allows tests to continue
const topicClusterId = parseInt(parts[4]);
const topicShard = parseInt(parts[5]);
if (!isNaN(topicClusterId) && !isNaN(topicShard)) {
console.log(
`Using pubsub topic with clusterId=${topicClusterId}, shard=${topicShard} instead`
);
const decoder = createDecoder(contentTopic, {
clusterId: topicClusterId,
shard: topicShard
});
try {
const subscription = await waku.filter.subscribe(
decoder,
callback ??
((_message) => {
console.log(_message);
})
);
return subscription;
} catch (innerErr: any) {
console.error(
`Error with alternative pubsub topic: ${innerErr.message}`
);
}
}
}
}
}
} catch (err) {
console.error(`Error checking node protocols: ${String(err)}`);
}
const decoder = createDecoder(contentTopic, {
clusterId,
shard
});
try {
const subscription = await waku.filter.subscribe(
decoder,
callback ??
((_message) => {
console.log(_message);
})
);
return subscription;
} catch (err: any) {
if (err.message && err.message.includes("Pubsub topic")) {
console.error(`Pubsub topic error: ${err.message}`);
console.log("Subscription failed, but continuing with empty result");
return {
unsubscribe: async () => {
console.log("No-op unsubscribe from failed subscription");
}
} as unknown as SubscribeResult;
}
throw err;
}
}
export const API = {
getPeerInfo,
getDebugInfo,
pushMessage,
createWakuNode,
startNode,
stopNode,
dialPeers,
subscribe
};

View File

@ -1,43 +1,63 @@
import { Browser, chromium, Page } from "@playwright/test";
import { Logger } from "@waku/utils";
const log = new Logger("browser-test");
// Global variable to store the browser and page
let browser: Browser | undefined;
let page: Page | undefined;
/**
* Initialize browser and load headless page
*/
export async function initBrowser(): Promise<void> {
browser = await chromium.launch({
headless: true
});
export async function initBrowser(appPort: number): Promise<void> {
try {
const launchArgs = ["--no-sandbox", "--disable-setuid-sandbox"];
if (!browser) {
throw new Error("Failed to initialize browser");
browser = await chromium.launch({
headless: true,
args: launchArgs
});
if (!browser) {
throw new Error("Failed to initialize browser");
}
page = await browser.newPage();
// Forward browser console to server logs
page.on('console', msg => {
const type = msg.type();
const text = msg.text();
log.info(`[Browser Console ${type.toUpperCase()}] ${text}`);
});
page.on('pageerror', error => {
log.error('[Browser Page Error]', error.message);
});
await page.goto(`http://localhost:${appPort}/app/index.html`, {
waitUntil: "networkidle",
});
await page.waitForFunction(
() => {
return window.wakuApi && typeof window.wakuApi.createWakuNode === "function";
},
{ timeout: 30000 }
);
log.info("Browser initialized successfully with wakuApi");
} catch (error) {
log.error("Error initializing browser:", error);
throw error;
}
page = await browser.newPage();
await page.goto("http://localhost:8080");
}
/**
* Get the current page instance
*/
export function getPage(): Page | undefined {
return page;
}
/**
* Set the page instance (for use by server.ts)
*/
export function setPage(pageInstance: Page | undefined): void {
page = pageInstance;
}
/**
* Closes the browser instance
*/
export async function closeBrowser(): Promise<void> {
if (browser) {
await browser.close();

View File

@ -1,89 +0,0 @@
// Message queue to store received messages by content topic
export interface QueuedMessage {
payload: number[] | undefined;
contentTopic: string;
timestamp: number;
receivedAt: number;
}
export interface MessageQueue {
[contentTopic: string]: QueuedMessage[];
}
// Global message queue storage
const messageQueue: MessageQueue = {};
/**
* Store a message in the queue
*/
export function storeMessage(message: QueuedMessage): void {
const { contentTopic } = message;
if (!messageQueue[contentTopic]) {
messageQueue[contentTopic] = [];
}
messageQueue[contentTopic].push(message);
}
/**
* Get messages for a specific content topic
*/
export function getMessages(
contentTopic: string,
options?: {
startTime?: number;
endTime?: number;
pageSize?: number;
ascending?: boolean;
}
): QueuedMessage[] {
if (!messageQueue[contentTopic]) {
return [];
}
let messages = [...messageQueue[contentTopic]];
// Filter by time if specified
if (options?.startTime || options?.endTime) {
messages = messages.filter((msg) => {
const afterStart = options.startTime
? msg.timestamp >= options.startTime
: true;
const beforeEnd = options.endTime
? msg.timestamp <= options.endTime
: true;
return afterStart && beforeEnd;
});
}
// Sort by timestamp
messages.sort((a, b) => {
return options?.ascending
? a.timestamp - b.timestamp
: b.timestamp - a.timestamp;
});
// Limit result size
if (options?.pageSize && options.pageSize > 0) {
messages = messages.slice(0, options.pageSize);
}
return messages;
}
/**
* Clear all messages from the queue
*/
export function clearQueue(): void {
Object.keys(messageQueue).forEach((topic) => {
delete messageQueue[topic];
});
}
/**
* Get all content topics in the queue
*/
export function getContentTopics(): string[] {
return Object.keys(messageQueue);
}

View File

@ -1,223 +0,0 @@
import express, { Request, Response, Router } from "express";
import { getPage } from "../browser/index.js";
const router = Router();
router.head("/admin/v1/create-node", (_req: Request, res: Response) => {
res.status(200).end();
});
router.head("/admin/v1/start-node", (_req: Request, res: Response) => {
res.status(200).end();
});
router.head("/admin/v1/stop-node", (_req: Request, res: Response) => {
res.status(200).end();
});
router.post("/admin/v1/create-node", (async (req: Request, res: Response) => {
try {
const {
defaultBootstrap = true,
networkConfig
} = req.body;
// Validate that networkConfig is provided
if (!networkConfig) {
return res.status(400).json({
code: 400,
message: "networkConfig is required"
});
}
// Validate that networkConfig has required properties
if (networkConfig.clusterId === undefined) {
return res.status(400).json({
code: 400,
message: "networkConfig.clusterId is required"
});
}
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(
({ defaultBootstrap, networkConfig }) => {
const nodeOptions: any = {
defaultBootstrap,
relay: {
advertise: true,
gossipsubOptions: {
allowPublishToZeroPeers: true
}
},
filter: true,
peers: [],
networkConfig: {
clusterId: networkConfig.clusterId,
shards: networkConfig.shards || [0]
}
};
return window.wakuAPI.createWakuNode(nodeOptions);
},
{ defaultBootstrap, networkConfig }
);
if (result && result.success) {
res.status(200).json({
success: true,
message: "Waku node created successfully"
});
} else {
res.status(500).json({
code: 500,
message: "Failed to create Waku node",
details: result?.error || "Unknown error"
});
}
} catch (error: any) {
res.status(500).json({
code: 500,
message: `Could not create Waku node: ${error.message}`
});
}
}) as express.RequestHandler);
// Start Waku node endpoint
router.post("/admin/v1/start-node", (async (_req: Request, res: Response) => {
try {
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(() => {
return window.wakuAPI.startNode
? window.wakuAPI.startNode()
: { error: "startNode function not available" };
});
if (result && !result.error) {
res.status(200).json({
success: true,
message: "Waku node started successfully"
});
} else {
res.status(500).json({
code: 500,
message: "Failed to start Waku node",
details: result?.error || "Unknown error"
});
}
} catch (error: any) {
res.status(500).json({
code: 500,
message: `Could not start Waku node: ${error.message}`
});
}
}) as express.RequestHandler);
// Stop Waku node endpoint
router.post("/admin/v1/stop-node", (async (_req: Request, res: Response) => {
try {
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(() => {
return window.wakuAPI.stopNode
? window.wakuAPI.stopNode()
: { error: "stopNode function not available" };
});
if (result && !result.error) {
res.status(200).json({
success: true,
message: "Waku node stopped successfully"
});
} else {
res.status(500).json({
code: 500,
message: "Failed to stop Waku node",
details: result?.error || "Unknown error"
});
}
} catch (error: any) {
res.status(500).json({
code: 500,
message: `Could not stop Waku node: ${error.message}`
});
}
}) as express.RequestHandler);
// Dial to peers endpoint
router.post("/admin/v1/peers", (async (req: Request, res: Response) => {
try {
const { peerMultiaddrs } = req.body;
if (!peerMultiaddrs || !Array.isArray(peerMultiaddrs)) {
return res.status(400).json({
code: 400,
message: "Invalid request. peerMultiaddrs array is required."
});
}
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(
({ peerAddrs }) => {
return window.wakuAPI.dialPeers(window.waku, peerAddrs);
},
{ peerAddrs: peerMultiaddrs }
);
if (result) {
res.status(200).json({
peersAdded: peerMultiaddrs.length - (result.errors?.length || 0),
peerErrors:
result.errors?.map((error: string, index: number) => {
return {
peerMultiaddr: peerMultiaddrs[index],
error
};
}) || []
});
} else {
res.status(500).json({
code: 500,
message: "Failed to dial peers"
});
}
} catch (error: any) {
res.status(500).json({
code: 500,
message: `Could not dial peers: ${error.message}`
});
}
}) as express.RequestHandler);
export default router;

View File

@ -1,51 +0,0 @@
import express, { Request, Response, Router } from "express";
import { getPage } from "../browser/index.js";
const router = Router();
// Get node info endpoint
router.get("/info", (async (_req: Request, res: Response) => {
try {
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(() => {
return window.wakuAPI.getPeerInfo(window.waku);
});
res.json(result);
} catch (error: any) {
console.error("Error getting info:", error);
res.status(500).json({ error: error.message });
}
}) as express.RequestHandler);
// Get node debug info endpoint
router.get("/debug/v1/info", (async (_req: Request, res: Response) => {
try {
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(() => {
return window.wakuAPI.getDebugInfo(window.waku);
});
res.json(result);
} catch (error: any) {
console.error("Error getting debug info:", error);
res.status(500).json({ error: error.message });
}
}) as express.RequestHandler);
export default router;

View File

@ -1,131 +0,0 @@
import express, { Request, Response, Router } from "express";
import { getPage } from "../browser/index.js";
const router = Router();
// Legacy push message endpoint
router.post("/push", (async (req: Request, res: Response) => {
try {
const { contentTopic, payload } = req.body;
if (!contentTopic) {
return res.status(400).json({
code: 400,
message: "Invalid request. contentTopic is required."
});
}
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(
({ topic, data }) => {
return window.wakuAPI.pushMessage(window.waku, topic, data);
},
{
topic: contentTopic,
data: payload
}
);
if (result) {
res.status(200).json({
messageId:
"0x" +
Buffer.from(contentTopic + Date.now().toString()).toString("hex")
});
} else {
res.status(503).json({
code: 503,
message: "Could not publish message: no suitable peers"
});
}
} catch (error: any) {
if (
error.message.includes("size exceeds") ||
error.message.includes("stream reset")
) {
res.status(503).json({
code: 503,
message:
"Could not publish message: message size exceeds gossipsub max message size"
});
} else {
res.status(500).json({
code: 500,
message: `Could not publish message: ${error.message}`
});
}
}
}) as express.RequestHandler);
// Waku REST API compatible push endpoint
router.post("/lightpush/v1/message", (async (req: Request, res: Response) => {
try {
const { message } = req.body;
if (!message || !message.contentTopic) {
return res.status(400).json({
code: 400,
message: "Invalid request. contentTopic is required."
});
}
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized"
});
}
const result = await page.evaluate(
({ contentTopic, payload }) => {
return window.wakuAPI.pushMessage(window.waku, contentTopic, payload);
},
{
contentTopic: message.contentTopic,
payload: message.payload
}
);
if (result) {
res.status(200).json({
messageId:
"0x" +
Buffer.from(message.contentTopic + Date.now().toString()).toString(
"hex"
)
});
} else {
res.status(503).json({
code: 503,
message: "Could not publish message: no suitable peers"
});
}
} catch (error: any) {
if (
error.message.includes("size exceeds") ||
error.message.includes("stream reset")
) {
res.status(503).json({
code: 503,
message:
"Could not publish message: message size exceeds gossipsub max message size"
});
} else {
res.status(500).json({
code: 500,
message: `Could not publish message: ${error.message}`
});
}
}
}) as express.RequestHandler);
export default router;

View File

@ -0,0 +1,87 @@
import { Router } from "express";
import { Logger } from "@waku/utils";
import {
createEndpointHandler,
validators,
errorHandlers,
} from "../utils/endpoint-handler.js";
interface LightPushResult {
successes: string[];
failures: Array<{ error: string; peerId?: string }>;
}
const log = new Logger("routes:waku");
const router = Router();
const corsEndpoints = [
"/waku/v1/wait-for-peers",
"/waku/v1/peer-info",
"/lightpush/v3/message",
];
corsEndpoints.forEach((endpoint) => {
router.head(endpoint, (_req, res) => {
res.status(200).end();
});
});
router.post(
"/waku/v1/wait-for-peers",
createEndpointHandler({
methodName: "waitForPeers",
validateInput: (body: unknown) => {
const bodyObj = body as { timeoutMs?: number; protocols?: string[] };
return [
bodyObj.timeoutMs || 10000,
bodyObj.protocols || ["lightpush", "filter"],
];
},
transformResult: () => ({
success: true,
message: "Successfully connected to peers",
}),
}),
);
router.get(
"/waku/v1/peer-info",
createEndpointHandler({
methodName: "getPeerInfo",
validateInput: validators.noInput,
}),
);
router.post(
"/lightpush/v3/message",
createEndpointHandler({
methodName: "pushMessageV3",
validateInput: (body: unknown): [string, string, string] => {
const validatedRequest = validators.requireLightpushV3(body);
return [
validatedRequest.message.contentTopic,
validatedRequest.message.payload,
validatedRequest.pubsubTopic,
];
},
handleError: errorHandlers.lightpushError,
transformResult: (result: unknown) => {
const lightPushResult = result as LightPushResult;
if (lightPushResult && lightPushResult.successes && lightPushResult.successes.length > 0) {
log.info("[Server] Message successfully sent via v3 lightpush!");
return {
success: true,
result: lightPushResult,
};
} else {
return {
success: false,
error: "Could not publish message: no suitable peers",
};
}
},
}),
);
export default router;

View File

@ -1,507 +1,244 @@
import { ChildProcess, exec } from "child_process";
import * as net from "net";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import * as path from "path";
import { chromium } from "@playwright/test";
import cors from "cors";
import express, { Request, Response } from "express";
import { Logger } from "@waku/utils";
import adminRouter from "./routes/admin.js";
import { setPage, getPage, closeBrowser } from "./browser/index.js";
import wakuRouter from "./routes/waku.js";
import { initBrowser, getPage, closeBrowser } from "./browser/index.js";
import {
DEFAULT_CLUSTER_ID,
DEFAULT_NUM_SHARDS,
Protocols,
AutoSharding,
StaticSharding,
} from "@waku/interfaces";
import { CreateNodeOptions } from "@waku/sdk";
import type { WindowNetworkConfig } from "../types/global.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
interface NodeError extends Error {
code?: string;
}
const log = new Logger("server");
const app = express();
app.use(cors());
app.use(express.json());
app.use(adminRouter);
let headlessServerProcess: ChildProcess | undefined;
import * as fs from "fs";
interface MessageQueue {
[contentTopic: string]: Array<{
payload: number[] | undefined;
contentTopic: string;
timestamp: number;
receivedAt: number;
}>;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const distRoot = path.resolve(__dirname, "..");
const webDir = path.resolve(distRoot, "web");
const messageQueue: MessageQueue = {};
async function startHeadlessServer(): Promise<void> {
return new Promise((resolve, reject) => {
try {
headlessServerProcess = exec(
`serve ${join(__dirname, "../../headless-tests")} -p 8080 -s`,
(error) => {
if (error) {
console.error(`Error starting serve: ${error}`);
return;
}
}
);
setTimeout(resolve, 2000);
} catch (error) {
console.error("Failed to start headless server:", error);
reject(error);
}
});
}
async function initBrowser(): Promise<void> {
app.get("/app/index.html", (_req: Request, res: Response) => {
try {
const browser = await chromium.launch({
headless: true
});
const htmlPath = path.join(webDir, "index.html");
let htmlContent = fs.readFileSync(htmlPath, "utf8");
if (!browser) {
throw new Error("Failed to initialize browser");
const networkConfig: WindowNetworkConfig = {};
if (process.env.WAKU_CLUSTER_ID) {
networkConfig.clusterId = parseInt(process.env.WAKU_CLUSTER_ID, 10);
}
if (process.env.WAKU_SHARD) {
networkConfig.shards = [parseInt(process.env.WAKU_SHARD, 10)];
log.info("Using static shard:", networkConfig.shards);
}
const page = await browser.newPage();
const lightpushNode = process.env.WAKU_LIGHTPUSH_NODE || null;
const enrBootstrap = process.env.WAKU_ENR_BOOTSTRAP || null;
try {
await checkServerAvailability("http://localhost:8080", 3);
await page.goto("http://localhost:8080");
} catch (error) {
console.error(
"Error loading headless app, continuing without it:",
error
);
await page.setContent(`
<html>
<head><title>Waku Test Environment</title></head>
<body>
<h1>Waku Test Environment (No headless app available)</h1>
<script>
window.waku = {};
window.wakuAPI = {
getPeerInfo: () => ({ peerId: "mock-peer-id", multiaddrs: [], peers: [] }),
getDebugInfo: () => ({ listenAddresses: [], peerId: "mock-peer-id", protocols: [] }),
pushMessage: () => ({ successes: [], failures: [{ error: "No headless app available" }] }),
dialPeers: () => ({ total: 0, errors: ["No headless app available"] }),
createWakuNode: () => ({ success: true, message: "Mock node created" }),
startNode: () => ({ success: true }),
stopNode: () => ({ success: true }),
subscribe: () => ({ unsubscribe: async () => {} })
};
</script>
</body>
</html>
`);
}
log.info("Network config on server start, pre headless:", networkConfig);
setPage(page);
const configScript = ` <script>
window.__WAKU_NETWORK_CONFIG = ${JSON.stringify(networkConfig)};
window.__WAKU_LIGHTPUSH_NODE = ${JSON.stringify(lightpushNode)};
window.__WAKU_ENR_BOOTSTRAP = ${JSON.stringify(enrBootstrap)};
</script>`;
const originalPattern =
' <script type="module" src="./index.js"></script>';
const replacement = `${configScript}\n <script type="module" src="./index.js"></script>`;
htmlContent = htmlContent.replace(originalPattern, replacement);
res.setHeader("Content-Type", "text/html");
res.send(htmlContent);
} catch (error) {
console.error("Error initializing browser:", error);
throw error;
log.error("Error serving dynamic index.html:", error);
res.status(500).send("Error loading page");
}
}
});
async function checkServerAvailability(
url: string,
retries = 3
): Promise<boolean> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, { method: "HEAD" });
if (response.ok) return true;
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
throw new Error(`Server at ${url} not available after ${retries} retries`);
}
app.use("/app", express.static(webDir, { index: false }));
async function findAvailablePort(
startPort: number,
maxAttempts = 10
): Promise<number> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const port = startPort + attempt;
try {
// Try to create a server on the port
await new Promise<void>((resolve, reject) => {
const server = net
.createServer()
.once("error", (err: any) => {
reject(err);
})
.once("listening", () => {
// If we can listen, the port is available
server.close();
resolve();
})
.listen(port);
});
app.use(wakuRouter);
// If we get here, the port is available
return port;
} catch (err) {
// Port is not available, continue to next port
}
}
// If we tried all ports and none are available, throw an error
throw new Error(
`Unable to find an available port after ${maxAttempts} attempts`
);
}
async function startServer(port: number = 3000): Promise<void> {
try {
await startHeadlessServer();
await initBrowser();
await startAPI(port);
} catch (error: any) {
console.error("Error starting server:", error);
}
}
async function startAPI(requestedPort: number): Promise<void> {
async function startAPI(requestedPort: number): Promise<number> {
try {
app.get("/", (_req: Request, res: Response) => {
res.json({ status: "Waku simulation server is running" });
});
app.get("/info", (async (_req: Request, res: Response) => {
try {
const result = await getPage()?.evaluate(() => {
return window.wakuAPI.getPeerInfo(window.waku);
});
res.json(result);
} catch (error: any) {
console.error("Error getting info:", error);
res.status(500).json({ error: error.message });
}
}) as express.RequestHandler);
app.get("/debug/v1/info", (async (_req: Request, res: Response) => {
try {
const result = await getPage()?.evaluate(() => {
return window.wakuAPI.getDebugInfo(window.waku);
});
res.json(result);
} catch (error: any) {
console.error("Error getting debug info:", error);
res.status(500).json({ error: error.message });
}
}) as express.RequestHandler);
app.post("/lightpush/v1/message", (async (req: Request, res: Response) => {
try {
const { message } = req.body;
if (!message || !message.contentTopic) {
return res.status(400).json({
code: 400,
message: "Invalid request. contentTopic is required."
});
}
const result = await getPage()?.evaluate(
({ contentTopic, payload }) => {
return window.wakuAPI.pushMessage(
window.waku,
contentTopic,
payload
);
},
{
contentTopic: message.contentTopic,
payload: message.payload
}
);
if (result) {
res.status(200).json({
messageId:
"0x" +
Buffer.from(
message.contentTopic + Date.now().toString()
).toString("hex")
});
} else {
res.status(503).json({
code: 503,
message: "Could not publish message: no suitable peers"
});
}
} catch (error: any) {
if (
error.message.includes("size exceeds") ||
error.message.includes("stream reset")
) {
res.status(503).json({
code: 503,
message:
"Could not publish message: message size exceeds gossipsub max message size"
});
} else {
res.status(500).json({
code: 500,
message: `Could not publish message: ${error.message}`
});
}
}
}) as express.RequestHandler);
app.get("/filter/v2/messages/:contentTopic", (async (
req: Request,
res: Response
) => {
try {
const { contentTopic } = req.params;
const { clusterId, shard } = req.query;
const options = {
clusterId: clusterId ? parseInt(clusterId as string, 10) : 42, // Default to match node creation
shard: shard ? parseInt(shard as string, 10) : 0 // Default to match node creation
};
// Set up SSE (Server-Sent Events)
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Function to send SSE
const sendSSE = (data: any): void => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Subscribe to messages
await getPage()?.evaluate(
({ contentTopic, options }) => {
// Message handler that will send messages back to the client
const callback = (message: any): void => {
// Post message to the browser context
window.postMessage(
{
type: "WAKU_MESSAGE",
payload: {
payload: message.payload
? Array.from(message.payload)
: undefined,
contentTopic: message.contentTopic,
timestamp: message.timestamp
}
},
"*"
);
};
return window.wakuAPI.subscribe(
window.waku,
contentTopic,
options,
callback
);
},
{ contentTopic, options }
);
// Set up event listener for messages from the page
await getPage()?.exposeFunction("sendMessageToServer", (message: any) => {
// Send the message as SSE
sendSSE(message);
const topic = message.contentTopic;
if (!messageQueue[topic]) {
messageQueue[topic] = [];
}
messageQueue[topic].push({
...message,
receivedAt: Date.now()
});
if (messageQueue[topic].length > 1000) {
messageQueue[topic].shift();
}
});
// Add event listener in the browser context to forward messages to the server
await getPage()?.evaluate(() => {
window.addEventListener("message", (event) => {
if (event.data.type === "WAKU_MESSAGE") {
(window as any).sendMessageToServer(event.data.payload);
}
});
});
req.on("close", () => {
});
} catch (error: any) {
console.error("Error in filter subscription:", error);
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
}
}) as express.RequestHandler);
app.get("/filter/v1/messages/:contentTopic", (async (
req: Request,
res: Response
) => {
try {
const { contentTopic } = req.params;
const {
pageSize = "20",
startTime,
endTime,
ascending = "false"
} = req.query;
if (!messageQueue[contentTopic]) {
return res.status(200).json({ messages: [] });
}
const limit = parseInt(pageSize as string, 10);
const isAscending = (ascending as string).toLowerCase() === "true";
const timeStart = startTime ? parseInt(startTime as string, 10) : 0;
const timeEnd = endTime ? parseInt(endTime as string, 10) : Date.now();
const filteredMessages = messageQueue[contentTopic]
.filter((msg) => {
const msgTime = msg.timestamp || msg.receivedAt;
return msgTime >= timeStart && msgTime <= timeEnd;
})
.sort((a, b) => {
const timeA = a.timestamp || a.receivedAt;
const timeB = b.timestamp || b.receivedAt;
return isAscending ? timeA - timeB : timeB - timeA;
})
.slice(0, limit);
// Format response to match Waku REST API format
const response = {
messages: filteredMessages.map((msg) => ({
payload: msg.payload
? Buffer.from(msg.payload).toString("base64")
: "",
contentTopic: msg.contentTopic,
timestamp: msg.timestamp,
version: 0 // Default version
}))
};
res.status(200).json(response);
} catch (error: any) {
console.error("Error retrieving messages:", error);
res.status(500).json({
code: 500,
message: `Failed to retrieve messages: ${error.message}`
});
}
}) as express.RequestHandler);
// Helper endpoint for executing functions (useful for testing)
app.post("/execute", (async (req: Request, res: Response) => {
try {
const { functionName, params = [] } = req.body;
if (functionName === "simulateMessages") {
const [contentTopic, messages] = params;
if (!messageQueue[contentTopic]) {
messageQueue[contentTopic] = [];
}
// Add messages to the queue
for (const msg of messages) {
messageQueue[contentTopic].push({
...msg,
contentTopic,
receivedAt: Date.now()
});
}
return res.status(200).json({
success: true,
messagesAdded: messages.length
});
}
const result = await getPage()?.evaluate(
({ fnName, fnParams }) => {
if (!window.wakuAPI[fnName]) {
return { error: `Function ${fnName} not found` };
}
return window.wakuAPI[fnName](...fnParams);
},
{ fnName: functionName, fnParams: params }
);
res.status(200).json(result);
} catch (error: any) {
console.error(
`Error executing function ${req.body.functionName}:`,
error
);
res.status(500).json({
error: error.message
});
}
}) as express.RequestHandler);
let actualPort: number;
try {
actualPort = await findAvailablePort(requestedPort);
} catch (error) {
console.error("Failed to find an available port:", error);
throw error;
}
app
.listen(actualPort, () => {
.listen(requestedPort, () => {
log.info(`API server running on http://localhost:${requestedPort}`);
})
.on("error", (error: any) => {
.on("error", (error: NodeError) => {
if (error.code === "EADDRINUSE") {
console.error(
`Port ${actualPort} is already in use. Please close the application using this port and try again.`
log.error(
`Port ${requestedPort} is already in use. Please close the application using this port and try again.`,
);
} else {
console.error("Error starting server:", error);
log.error("Error starting server:", error);
}
throw error;
});
return Promise.resolve();
} catch (error: any) {
console.error("Error starting server:", error);
return Promise.reject(error);
return requestedPort;
} catch (error) {
log.error("Error starting server:", error);
throw error;
}
}
process.on("SIGINT", (async () => {
await closeBrowser();
async function startServer(port: number = 3000): Promise<void> {
try {
const actualPort = await startAPI(port);
await initBrowser(actualPort);
if (headlessServerProcess && headlessServerProcess.pid) {
try {
process.kill(headlessServerProcess.pid);
log.info("Auto-starting node with CLI configuration...");
const hasEnrBootstrap = Boolean(process.env.WAKU_ENR_BOOTSTRAP);
const networkConfig: AutoSharding | StaticSharding = process.env.WAKU_SHARD
? ({
clusterId: process.env.WAKU_CLUSTER_ID
? parseInt(process.env.WAKU_CLUSTER_ID, 10)
: DEFAULT_CLUSTER_ID,
shards: [parseInt(process.env.WAKU_SHARD, 10)],
} as StaticSharding)
: ({
clusterId: process.env.WAKU_CLUSTER_ID
? parseInt(process.env.WAKU_CLUSTER_ID, 10)
: DEFAULT_CLUSTER_ID,
numShardsInCluster: DEFAULT_NUM_SHARDS,
} as AutoSharding);
const createOptions: CreateNodeOptions = {
defaultBootstrap: false,
...(hasEnrBootstrap && {
discovery: {
dns: true,
peerExchange: true,
peerCache: true,
},
}),
networkConfig,
};
log.info(
`Bootstrap mode: ${hasEnrBootstrap ? "ENR-only (defaultBootstrap=false)" : "default bootstrap (defaultBootstrap=true)"}`,
);
if (hasEnrBootstrap) {
log.info(`ENR bootstrap peers: ${process.env.WAKU_ENR_BOOTSTRAP}`);
}
log.info(
`Network config: ${JSON.stringify(networkConfig)}`,
);
await getPage()?.evaluate((config) => {
return window.wakuApi.createWakuNode(config);
}, createOptions);
await getPage()?.evaluate(() => window.wakuApi.startNode());
try {
await getPage()?.evaluate(() =>
window.wakuApi.waitForPeers?.(5000, [Protocols.LightPush]),
);
log.info("Auto-start completed with bootstrap peers");
} catch (peerError) {
log.info(
"Auto-start completed (no bootstrap peers found - may be expected with test ENRs)",
);
}
} catch (e) {
// Process already stopped
log.warn("Auto-start failed:", e);
}
} catch (error) {
log.error("Error starting server:", error);
}
}
process.on("uncaughtException", (error) => {
log.error("Uncaught Exception:", error);
if (process.env.NODE_ENV !== "production") {
process.exit(1);
}
});
process.on("unhandledRejection", (reason, promise) => {
log.error("Unhandled Rejection at:", promise, "reason:", reason);
if (process.env.NODE_ENV !== "production") {
process.exit(1);
}
});
const gracefulShutdown = async (signal: string) => {
log.info(`Received ${signal}, gracefully shutting down...`);
try {
await closeBrowser();
} catch (e) {
log.warn("Error closing browser:", e);
}
process.exit(0);
};
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
function parseCliArgs() {
const args = process.argv.slice(2);
let clusterId: number | undefined;
let shard: number | undefined;
for (const arg of args) {
if (arg.startsWith("--cluster-id=")) {
clusterId = parseInt(arg.split("=")[1], 10);
if (isNaN(clusterId)) {
log.error("Invalid cluster-id value. Must be a number.");
process.exit(1);
}
} else if (arg.startsWith("--shard=")) {
shard = parseInt(arg.split("=")[1], 10);
if (isNaN(shard)) {
log.error("Invalid shard value. Must be a number.");
process.exit(1);
}
}
}
process.exit(0);
}) as any);
return { clusterId, shard };
}
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
const cliArgs = parseCliArgs();
if (cliArgs.clusterId !== undefined) {
process.env.WAKU_CLUSTER_ID = cliArgs.clusterId.toString();
log.info(`Using CLI cluster ID: ${cliArgs.clusterId}`);
}
if (cliArgs.shard !== undefined) {
process.env.WAKU_SHARD = cliArgs.shard.toString();
log.info(`Using CLI shard: ${cliArgs.shard}`);
}
void startServer(port);
}

View File

@ -1,8 +0,0 @@
import { readFileSync } from "fs";
import { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
export const __dirname = dirname(__filename);
export const readJSON = (path) => JSON.parse(readFileSync(path, "utf-8"));

View File

@ -0,0 +1,197 @@
import { Request, Response } from "express";
import { Logger } from "@waku/utils";
import { getPage } from "../browser/index.js";
import type { ITestBrowser } from "../../types/global.js";
const log = new Logger("endpoint-handler");
export interface LightpushV3Request {
pubsubTopic: string;
message: {
payload: string;
contentTopic: string;
version: number;
};
}
export interface LightpushV3Response {
success?: boolean;
error?: string;
result?: {
successes: string[];
failures: Array<{
error: string;
peerId?: string;
}>;
};
}
export interface EndpointConfig<TInput = unknown, TOutput = unknown> {
methodName: string;
validateInput?: (_requestBody: unknown) => TInput;
transformResult?: (_sdkResult: unknown) => TOutput;
handleError?: (_caughtError: Error) => { code: number; message: string };
preCheck?: () => Promise<void> | void;
logResult?: boolean;
}
export function createEndpointHandler<TInput = unknown, TOutput = unknown>(
config: EndpointConfig<TInput, TOutput>,
) {
return async (req: Request, res: Response) => {
try {
let input: TInput;
try {
input = config.validateInput
? config.validateInput(req.body)
: req.body;
} catch (validationError) {
return res.status(400).json({
code: 400,
message: `Invalid input: ${validationError instanceof Error ? validationError.message : String(validationError)}`,
});
}
if (config.preCheck) {
try {
await config.preCheck();
} catch (checkError) {
return res.status(503).json({
code: 503,
message: checkError instanceof Error ? checkError.message : String(checkError),
});
}
}
const page = getPage();
if (!page) {
return res.status(503).json({
code: 503,
message: "Browser not initialized",
});
}
const result = await page.evaluate(
({ methodName, params }) => {
const testWindow = window as ITestBrowser;
if (!testWindow.wakuApi) {
throw new Error("window.wakuApi is not available");
}
const wakuApi = testWindow.wakuApi as unknown as Record<string, unknown>;
const method = wakuApi[methodName];
if (typeof method !== "function") {
throw new Error(`window.wakuApi.${methodName} is not a function`);
}
if (params === null || params === undefined) {
return method.call(testWindow.wakuApi);
} else if (Array.isArray(params)) {
return method.apply(testWindow.wakuApi, params);
} else {
return method.call(testWindow.wakuApi, params);
}
},
{ methodName: config.methodName, params: input },
);
if (config.logResult !== false) {
log.info(
`[${config.methodName}] Result:`,
JSON.stringify(result, null, 2),
);
}
const finalResult = config.transformResult
? config.transformResult(result)
: result;
res.status(200).json(finalResult);
} catch (error) {
if (config.handleError) {
const errorResponse = config.handleError(error as Error);
return res.status(errorResponse.code).json({
code: errorResponse.code,
message: errorResponse.message,
});
}
log.error(`[${config.methodName}] Error:`, error);
res.status(500).json({
code: 500,
message: `Could not execute ${config.methodName}: ${error instanceof Error ? error.message : String(error)}`,
});
}
};
}
export const validators = {
requireLightpushV3: (body: unknown): LightpushV3Request => {
// Type guard to check if body is an object
if (!body || typeof body !== "object") {
throw new Error("Request body must be an object");
}
const bodyObj = body as Record<string, unknown>;
if (
bodyObj.pubsubTopic !== undefined &&
typeof bodyObj.pubsubTopic !== "string"
) {
throw new Error("pubsubTopic must be a string if provided");
}
if (!bodyObj.message || typeof bodyObj.message !== "object") {
throw new Error("message is required and must be an object");
}
const message = bodyObj.message as Record<string, unknown>;
if (
!message.contentTopic ||
typeof message.contentTopic !== "string"
) {
throw new Error("message.contentTopic is required and must be a string");
}
if (!message.payload || typeof message.payload !== "string") {
throw new Error(
"message.payload is required and must be a string (base64 encoded)",
);
}
if (
message.version !== undefined &&
typeof message.version !== "number"
) {
throw new Error("message.version must be a number if provided");
}
return {
pubsubTopic: (bodyObj.pubsubTopic as string) || "",
message: {
payload: message.payload as string,
contentTopic: message.contentTopic as string,
version: (message.version as number) || 1,
},
};
},
noInput: () => null,
};
export const errorHandlers = {
lightpushError: (error: Error) => {
if (
error.message.includes("size exceeds") ||
error.message.includes("stream reset")
) {
return {
code: 503,
message:
"Could not publish message: message size exceeds gossipsub max message size",
};
}
return {
code: 500,
message: `Could not publish message: ${error.message}`,
};
},
};

View File

@ -0,0 +1,117 @@
import { test, expect } from "@playwright/test";
import axios from "axios";
import { StartedTestContainer } from "testcontainers";
import { DefaultTestRoutingInfo } from "@waku/tests";
import {
startBrowserTestsContainer,
stopContainer
} from "./utils/container-helpers.js";
import {
createTwoNodeNetwork,
getDockerAccessibleMultiaddr,
stopNwakuNodes,
TwoNodeNetwork
} from "./utils/nwaku-helpers.js";
import {
ENV_BUILDERS,
TEST_CONFIG,
ASSERTIONS
} from "./utils/test-config.js";
test.describe.configure({ mode: "serial" });
let container: StartedTestContainer;
let nwakuNodes: TwoNodeNetwork;
let baseUrl: string;
test.beforeAll(async () => {
nwakuNodes = await createTwoNodeNetwork();
const lightPushPeerAddr = await getDockerAccessibleMultiaddr(nwakuNodes.nodes[0]);
const result = await startBrowserTestsContainer({
environment: {
...ENV_BUILDERS.withLocalLightPush(lightPushPeerAddr),
DEBUG: "waku:*",
WAKU_LIGHTPUSH_NODE: lightPushPeerAddr,
},
networkMode: "waku",
});
container = result.container;
baseUrl = result.baseUrl;
});
test.afterAll(async () => {
await Promise.all([
stopContainer(container),
stopNwakuNodes(nwakuNodes?.nodes || []),
]);
});
test("WakuHeadless can discover nwaku peer and use it for light push", async () => {
test.setTimeout(TEST_CONFIG.DEFAULT_TEST_TIMEOUT);
const contentTopic = TEST_CONFIG.DEFAULT_CONTENT_TOPIC;
const testMessage = TEST_CONFIG.DEFAULT_TEST_MESSAGE;
await new Promise((r) => setTimeout(r, TEST_CONFIG.WAKU_INIT_DELAY));
const healthResponse = await axios.get(`${baseUrl}/`, { timeout: 5000 });
ASSERTIONS.serverHealth(healthResponse);
try {
await axios.post(`${baseUrl}/waku/v1/wait-for-peers`, {
timeoutMs: 10000,
protocols: ["lightpush"],
}, { timeout: 15000 });
} catch {
// Ignore errors
}
const peerInfoResponse = await axios.get(`${baseUrl}/waku/v1/peer-info`);
ASSERTIONS.peerInfo(peerInfoResponse);
const routingInfo = DefaultTestRoutingInfo;
const subscriptionResults = await Promise.all([
nwakuNodes.nodes[0].ensureSubscriptions([routingInfo.pubsubTopic]),
nwakuNodes.nodes[1].ensureSubscriptions([routingInfo.pubsubTopic])
]);
expect(subscriptionResults[0]).toBe(true);
expect(subscriptionResults[1]).toBe(true);
await new Promise((r) => setTimeout(r, TEST_CONFIG.SUBSCRIPTION_DELAY));
const base64Payload = btoa(testMessage);
const pushResponse = await axios.post(`${baseUrl}/lightpush/v3/message`, {
pubsubTopic: routingInfo.pubsubTopic,
message: {
contentTopic,
payload: base64Payload,
version: 1,
},
});
ASSERTIONS.lightPushV3Success(pushResponse);
await new Promise((r) => setTimeout(r, TEST_CONFIG.MESSAGE_PROPAGATION_DELAY));
const [node1Messages, node2Messages] = await Promise.all([
nwakuNodes.nodes[0].messages(contentTopic),
nwakuNodes.nodes[1].messages(contentTopic)
]);
const totalMessages = node1Messages.length + node2Messages.length;
expect(totalMessages).toBeGreaterThanOrEqual(1);
const receivedMessages = [...node1Messages, ...node2Messages];
expect(receivedMessages.length).toBeGreaterThan(0);
const receivedMessage = receivedMessages[0];
ASSERTIONS.messageContent(receivedMessage, testMessage, contentTopic);
});

View File

@ -1,136 +0,0 @@
import { expect, test } from "@playwright/test";
import { LightNode } from "@waku/sdk";
import { API } from "../src/api/shared.js";
import { NETWORK_CONFIG, ACTIVE_PEERS } from "./test-config.js";
// Define the window interface for TypeScript
declare global {
// eslint-disable-next-line no-unused-vars
interface Window {
waku: LightNode;
wakuAPI: typeof API;
}
}
test.describe("waku", () => {
test.beforeEach(async ({ page }) => {
await page.goto("");
await page.waitForTimeout(5000);
// Create and initialize a fresh Waku node for each test
const setupResult = await page.evaluate(async (config) => {
try {
await window.wakuAPI.createWakuNode({
...config.defaultNodeConfig,
networkConfig: config.networkConfig
});
await window.wakuAPI.startNode();
return { success: true };
} catch (error) {
console.error("Failed to initialize Waku node:", error);
return { success: false, error: String(error) };
}
}, NETWORK_CONFIG);
expect(setupResult.success).toBe(true);
});
test("can get peer id", async ({ page }) => {
const peerId = await page.evaluate(() => {
return window.waku.libp2p.peerId.toString();
});
expect(peerId).toBeDefined();
console.log("Peer ID:", peerId);
});
test("can get info", async ({ page }) => {
const info = await page.evaluate(() => {
return window.wakuAPI.getPeerInfo(window.waku);
});
expect(info).toBeDefined();
expect(info.peerId).toBeDefined();
expect(info.multiaddrs).toBeDefined();
expect(info.peers).toBeDefined();
console.log("Info:", info);
});
test("can get debug info", async ({ page }) => {
const debug = await page.evaluate(() => {
return window.wakuAPI.getDebugInfo(window.waku);
});
expect(debug).toBeDefined();
expect(debug.listenAddresses).toBeDefined();
expect(debug.peerId).toBeDefined();
expect(debug.protocols).toBeDefined();
console.log("Debug:", debug);
});
test("can dial peers", async ({ page }) => {
const result = await page.evaluate((peerAddrs) => {
return window.wakuAPI.dialPeers(window.waku, peerAddrs);
}, ACTIVE_PEERS);
expect(result).toBeDefined();
expect(result.total).toBe(ACTIVE_PEERS.length);
expect(result.errors.length >= result.total).toBe(false);
console.log("Dial result:", result);
});
test("can push a message", async ({ page }) => {
// First dial to peers
await page.evaluate((peersToDial) => {
return window.wakuAPI.dialPeers(window.waku, peersToDial);
}, ACTIVE_PEERS);
// Create a test message
const contentTopic = NETWORK_CONFIG.testMessage.contentTopic;
const payload = new TextEncoder().encode(NETWORK_CONFIG.testMessage.payload);
const arrayPayload = Array.from(payload);
// Push the message
const result = await page.evaluate(
({ topic, data }) => {
return window.wakuAPI.pushMessage(
window.waku,
topic,
new Uint8Array(data)
);
},
{ topic: contentTopic, data: arrayPayload }
);
expect(result).toBeDefined();
console.log("Push result:", result);
});
test("can recreate Waku node", async ({ page }) => {
// Get the current node's peer ID
const initialPeerId = await page.evaluate(() => {
return window.waku.libp2p.peerId.toString();
});
// Create a new node with different parameters
const result = await page.evaluate(() => {
return window.wakuAPI.createWakuNode({
defaultBootstrap: true // Different from beforeEach
});
});
expect(result.success).toBe(true);
// Start the new node
await page.evaluate(() => window.wakuAPI.startNode());
// Get the new peer ID
const newPeerId = await page.evaluate(() => {
return window.waku.libp2p.peerId.toString();
});
expect(newPeerId).not.toBe(initialPeerId);
console.log("Initial:", initialPeerId, "New:", newPeerId);
});
});

View File

@ -0,0 +1,134 @@
import { test, expect } from "@playwright/test";
import axios from "axios";
import { StartedTestContainer } from "testcontainers";
import {
createLightNode,
LightNode,
Protocols,
IDecodedMessage,
} from "@waku/sdk";
import { DEFAULT_CLUSTER_ID, DEFAULT_NUM_SHARDS } from "@waku/interfaces";
import { startBrowserTestsContainer, stopContainer } from "./utils/container-helpers.js";
import { ENV_BUILDERS, TEST_CONFIG } from "./utils/test-config.js";
test.describe.configure({ mode: "serial" });
let container: StartedTestContainer;
let baseUrl: string;
let wakuNode: LightNode;
test.beforeAll(async () => {
const result = await startBrowserTestsContainer({
environment: {
...ENV_BUILDERS.withProductionEnr(),
DEBUG: "waku:*",
},
});
container = result.container;
baseUrl = result.baseUrl;
});
test.afterAll(async () => {
if (wakuNode) {
try {
await wakuNode.stop();
} catch {
// Ignore errors
}
}
await stopContainer(container);
});
test("cross-network message delivery: SDK light node receives server lightpush", async () => {
test.setTimeout(TEST_CONFIG.DEFAULT_TEST_TIMEOUT);
const contentTopic = TEST_CONFIG.DEFAULT_CONTENT_TOPIC;
const testMessage = TEST_CONFIG.DEFAULT_TEST_MESSAGE;
wakuNode = await createLightNode({
defaultBootstrap: true,
discovery: {
dns: true,
peerExchange: true,
peerCache: true,
},
networkConfig: {
clusterId: DEFAULT_CLUSTER_ID,
numShardsInCluster: DEFAULT_NUM_SHARDS,
},
libp2p: {
filterMultiaddrs: false,
},
});
await wakuNode.start();
await wakuNode.waitForPeers(
[Protocols.Filter, Protocols.LightPush],
30000,
);
const messages: IDecodedMessage[] = [];
const decoder = wakuNode.createDecoder({ contentTopic });
if (
!(await wakuNode.filter.subscribe([decoder], (message) => {
messages.push(message);
}))
) {
throw new Error("Failed to subscribe to Filter");
}
await new Promise((r) => setTimeout(r, 2000));
const messagePromise = new Promise<void>((resolve) => {
const originalLength = messages.length;
const checkForMessage = () => {
if (messages.length > originalLength) {
resolve();
} else {
setTimeout(checkForMessage, 100);
}
};
checkForMessage();
});
await axios.post(`${baseUrl}/waku/v1/wait-for-peers`, {
timeoutMs: 30000, // Increased timeout
protocols: ["lightpush", "filter"],
});
await new Promise((r) => setTimeout(r, 10000));
const base64Payload = btoa(testMessage);
const pushResponse = await axios.post(`${baseUrl}/lightpush/v3/message`, {
pubsubTopic: decoder.pubsubTopic,
message: {
contentTopic,
payload: base64Payload,
version: 1,
},
});
expect(pushResponse.status).toBe(200);
expect(pushResponse.data.success).toBe(true);
await Promise.race([
messagePromise,
new Promise((_, reject) =>
setTimeout(() => {
reject(new Error("Timeout waiting for message"));
}, 45000),
),
]);
expect(messages).toHaveLength(1);
const receivedMessage = messages[0];
expect(receivedMessage.contentTopic).toBe(contentTopic);
const receivedPayload = new TextDecoder().decode(receivedMessage.payload);
expect(receivedPayload).toBe(testMessage);
});

View File

@ -1,722 +1,82 @@
import { ChildProcess, exec, spawn } from "child_process";
import * as http from "http";
import * as net from "net";
import { join } from "path";
import { expect, test } from "@playwright/test";
import { test, expect } from "@playwright/test";
import axios from "axios";
import { spawn, ChildProcess } from "child_process";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
// The default URL, but we'll update this if we detect a different port
let API_URL = "http://localhost:3000";
// Need this for basic node initialization that doesn't rely on /execute
const PEERS = [
"/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ",
"/dns4/waku.fryorcraken.xyz/tcp/8000/wss/p2p/16Uiu2HAmMRvhDHrtiHft1FTUYnn6cVA8AWVrTyLUayJJ3MWpUZDB"
];
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let serverProcess: ChildProcess;
// Force tests to run sequentially to avoid port conflicts
test.describe.configure({ mode: "serial" });
// Helper function to check if a port is in use
async function isPortInUse(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net
.createServer()
.once("error", () => {
// Port is in use
resolve(true);
})
.once("listening", () => {
// Port is free, close server
server.close();
resolve(false);
})
.listen(port);
});
}
test.describe("Server Tests", () => {
let serverProcess: ChildProcess;
let baseUrl = "http://localhost:3000";
// Helper function to kill processes on port 3000
async function killProcessOnPort(): Promise<void> {
return new Promise<void>((resolve) => {
// Different commands for different platforms
const cmd =
process.platform === "win32"
? `netstat -ano | findstr :3000 | findstr LISTENING`
: `lsof -i:3000 -t`;
test.beforeAll(async () => {
const serverPath = join(__dirname, "..", "dist", "src", "server.js");
exec(cmd, (err, stdout) => {
if (err || !stdout.trim()) {
console.log("No process running on port 3000");
resolve();
return;
}
console.log(`Found processes on port 3000: ${stdout.trim()}`);
// Kill the process
const killCmd =
process.platform === "win32"
? `FOR /F "tokens=5" %P IN ('netstat -ano ^| findstr :3000 ^| findstr LISTENING') DO taskkill /F /PID %P`
: `kill -9 ${stdout.trim()}`;
exec(killCmd, (killErr) => {
if (killErr) {
console.error(`Error killing process: ${killErr.message}`);
} else {
console.log("Killed process on port 3000");
}
// Wait a moment for OS to release the port
setTimeout(resolve, 500);
});
serverProcess = spawn("node", [serverPath], {
stdio: "pipe",
env: { ...process.env, PORT: "3000" }
});
});
}
// Helper function to wait for the API server to be available
async function waitForApiServer(
maxRetries = 10,
interval = 1000
): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await axios.get(API_URL, { timeout: 2000 });
if (response.status === 200) {
console.log(`API server is available at ${API_URL}`);
return true;
}
} catch (e) {
console.log(
`API server not available at ${API_URL}, retrying (${i + 1}/${maxRetries})...`
);
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
console.warn(
`API server at ${API_URL} not available after ${maxRetries} attempts`
);
return false;
}
serverProcess.stdout?.on("data", (_data: Buffer) => {
});
// Setup and teardown for the whole test suite
test.beforeAll(async () => {
// First check if port 3000 is already in use - if so, try to kill it
const portInUse = await isPortInUse(3000);
if (portInUse) {
console.log(
"Port 3000 is already in use. Attempting to kill the process..."
);
await killProcessOnPort();
serverProcess.stderr?.on("data", (_data: Buffer) => {
});
// Check again
const stillInUse = await isPortInUse(3000);
if (stillInUse) {
console.log("Failed to free port 3000. Waiting for it to be released...");
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
await new Promise((resolve) => setTimeout(resolve, 3000));
// Start the server
console.log("Starting server for tests...");
serverProcess = spawn("node", [join(process.cwd(), "dist/server.js")], {
stdio: "pipe",
detached: true
});
// Log server output for debugging and capture the actual port
serverProcess.stdout?.on("data", (data) => {
const output = data.toString();
console.log(`Server: ${output}`);
// Check if the output contains the port information
const portMatch = output.match(
/API server running on http:\/\/localhost:(\d+)/
);
if (portMatch && portMatch[1]) {
const detectedPort = parseInt(portMatch[1], 10);
if (detectedPort !== 3000) {
console.log(
`Server is running on port ${detectedPort} instead of 3000`
);
API_URL = `http://localhost:${detectedPort}`;
}
}
});
serverProcess.stderr?.on("data", (data) => {
console.error(`Server Error: ${data}`);
});
// Wait for server to start and API to be available
console.log("Waiting for server to start...");
await new Promise((resolve) => setTimeout(resolve, 5000));
const apiAvailable = await waitForApiServer();
if (!apiAvailable) {
console.warn("API server is not available, tests may fail");
}
if (apiAvailable) {
// Create a node for the tests
try {
console.log("Creating node for tests...");
const createNodeResponse = await axios.post(
`${API_URL}/admin/v1/create-node`,
{
defaultBootstrap: false,
networkConfig: {
clusterId: 42,
shards: [0]
},
pubsubTopics: ["/waku/2/rs/42/0"] // Explicitly configure the pubsub topic
},
{ timeout: 10000 }
);
if (createNodeResponse.status === 200) {
console.log("Node creation response:", createNodeResponse.data);
// Start the node
const startNodeResponse = await axios.post(
`${API_URL}/admin/v1/start-node`,
{},
{ timeout: 5000 }
);
if (startNodeResponse.status === 200) {
console.log("Node started successfully");
}
}
} catch (error) {
console.warn(
"Failed to create/start node through API, some tests may fail:",
error
);
}
} else {
console.warn(
"Skipping node creation as server doesn't appear to be running"
);
}
});
test.afterAll(async () => {
// Stop the server
console.log("Stopping server...");
if (serverProcess && serverProcess.pid) {
if (process.platform === "win32") {
spawn("taskkill", ["/pid", serverProcess.pid.toString(), "/f", "/t"]);
} else {
// Ensure the process and all its children are terminated
let serverReady = false;
for (let i = 0; i < 30; i++) {
try {
process.kill(-serverProcess.pid, "SIGINT");
} catch (e) {
console.log("Server process already terminated");
}
}
}
// Verify no processes running on port 3000
await killProcessOnPort();
// Give time for all processes to terminate
await new Promise((resolve) => setTimeout(resolve, 1000));
});
test.describe("Waku Server API", () => {
// Direct test of filter endpoint - this runs first
test("can directly access filter/v1/messages endpoint", async () => {
// Try with different content topic formats
const testTopics = [
"test-topic",
"/test/topic",
"%2Ftest%2Ftopic", // Pre-encoded
"%2Ftest%2Ftopic" // Pre-encoded
];
for (const topic of testTopics) {
console.log(`Testing direct access with topic: ${topic}`);
try {
const response = await axios.get(
`${API_URL}/filter/v1/messages/${topic}`,
{
timeout: 5000,
validateStatus: () => true
}
);
console.log(` Status: ${response.status}`);
console.log(` Content-Type: ${response.headers["content-type"]}`);
console.log(` Data: ${JSON.stringify(response.data)}`);
// If this succeeds, we'll use this topic format for our tests
if (response.status === 200) {
console.log(` Found working topic format: ${topic}`);
const res = await axios.get(`${baseUrl}/`, { timeout: 2000 });
if (res.status === 200) {
serverReady = true;
break;
}
} catch (error: any) {
console.error(` Error with topic ${topic}:`, error.message);
if (error.response) {
console.error(` Response status: ${error.response.status}`);
}
} catch {
// Ignore errors
}
await new Promise((r) => setTimeout(r, 1000));
}
expect(serverReady).toBe(true);
});
test.afterAll(async () => {
if (serverProcess) {
serverProcess.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
}
});
// This test checks if the server is running and can serve the basic endpoints
test("can get server status and verify endpoints", async () => {
// Get initial server status with retry mechanism
let initialResponse;
for (let attempt = 0; attempt < 5; attempt++) {
try {
initialResponse = await axios.get(`${API_URL}/`, {
timeout: 5000,
validateStatus: () => true // Accept any status code
});
if (initialResponse.status === 200) {
break;
}
} catch (e) {
console.log(
`Server not responding on attempt ${attempt + 1}/5, retrying...`
);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
test("server health endpoint", async () => {
const res = await axios.get(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.data.status).toBe("Waku simulation server is running");
});
// If we still couldn't connect, skip this test
if (!initialResponse || initialResponse.status !== 200) {
console.warn("Server is not responding, skipping endpoint checks");
test.skip();
return;
}
test("static files are served", async () => {
const htmlRes = await axios.get(`${baseUrl}/app/index.html`);
expect(htmlRes.status).toBe(200);
expect(htmlRes.data).toContain("Waku Test Environment");
expect(initialResponse.status).toBe(200);
expect(initialResponse.data.status).toBe(
"Waku simulation server is running"
);
// Check if key endpoints are available
console.log("Checking if server endpoints are properly registered...");
const jsRes = await axios.get(`${baseUrl}/app/index.js`);
expect(jsRes.status).toBe(200);
expect(jsRes.data).toContain("WakuHeadless");
});
test("Waku node auto-started", async () => {
try {
// Try to access the various endpoints with simple HEAD requests
const endpoints = [
"/info",
"/debug/v1/info",
"/admin/v1/create-node",
"/admin/v1/start-node",
"/admin/v1/stop-node",
"/filter/v1/messages/test-topic",
"/filter/v2/messages/test-topic"
];
for (const endpoint of endpoints) {
try {
const response = await axios.head(`${API_URL}${endpoint}`, {
validateStatus: () => true, // Accept any status code
timeout: 3000 // Short timeout to avoid hanging
});
// Some endpoints may return 404 or 405 if they only support specific methods,
// but at least we should get a response if the route is registered
console.log(`Endpoint ${endpoint}: Status ${response.status}`);
// If we get a 404, the route is not registered
expect(response.status).not.toBe(404);
} catch (error) {
console.warn(`Error checking endpoint ${endpoint}:`, error.message);
// Continue checking other endpoints even if one fails
}
}
} catch (error: any) {
console.error("Error checking endpoints:", error.message);
throw error;
}
});
// Test node lifecycle operations using the dedicated endpoints
test("can create, start, and stop a node", async () => {
// 1. Create a new node
const createResponse = await axios.post(`${API_URL}/admin/v1/create-node`, {
defaultBootstrap: true,
networkConfig: {
clusterId: 42,
shards: [0]
},
pubsubTopics: ["/waku/2/rs/42/0"] // Explicitly configure the pubsub topic
});
expect(createResponse.status).toBe(200);
expect(createResponse.data.success).toBe(true);
// 2. Start the node
const startResponse = await axios.post(`${API_URL}/admin/v1/start-node`);
expect(startResponse.status).toBe(200);
expect(startResponse.data.success).toBe(true);
// 3. Get info to verify it's running
const infoResponse = await axios.get(`${API_URL}/info`);
expect(infoResponse.status).toBe(200);
expect(infoResponse.data.peerId).toBeDefined();
console.log("Node peer ID:", infoResponse.data.peerId);
// 4. Stop the node
const stopResponse = await axios.post(`${API_URL}/admin/v1/stop-node`);
expect(stopResponse.status).toBe(200);
expect(stopResponse.data.success).toBe(true);
// 5. Start it again
const restartResponse = await axios.post(`${API_URL}/admin/v1/start-node`);
expect(restartResponse.status).toBe(200);
expect(restartResponse.data.success).toBe(true);
// 6. Verify it's running again
const finalInfoResponse = await axios.get(`${API_URL}/info`);
expect(finalInfoResponse.status).toBe(200);
expect(finalInfoResponse.data.peerId).toBeDefined();
});
// This test requires a running node, which we now can properly initialize with our new endpoints
test("can connect to peers and get node info", async () => {
// Create and start a fresh node
await axios.post(`${API_URL}/admin/v1/create-node`, {
defaultBootstrap: false,
networkConfig: {
clusterId: 42,
shards: [0]
},
pubsubTopics: ["/waku/2/rs/42/0"] // Explicitly configure the pubsub topic
});
await axios.post(`${API_URL}/admin/v1/start-node`);
// FilterConnect to peers
const dialResponse = await axios.post(`${API_URL}/admin/v1/peers`, {
peerMultiaddrs: PEERS
});
expect(dialResponse.status).toBe(200);
console.log("Peer connection response:", dialResponse.data);
// Get debug info now that we have a properly initialized node
const debugResponse = await axios.get(`${API_URL}/debug/v1/info`);
expect(debugResponse.status).toBe(200);
expect(debugResponse.data).toBeDefined();
// Log protocols available
if (debugResponse.data.protocols) {
const wakuProtocols = debugResponse.data.protocols.filter((p: string) =>
p.includes("/waku/")
);
console.log("Waku protocols:", wakuProtocols);
}
});
test("can push messages", async () => {
// Create and start a fresh node
await axios.post(`${API_URL}/admin/v1/create-node`, {
defaultBootstrap: true,
networkConfig: {
clusterId: 42,
shards: [0]
},
pubsubTopics: ["/waku/2/rs/42/0"] // Explicitly configure the pubsub topic
});
await axios.post(`${API_URL}/admin/v1/start-node`);
// FilterConnect to peers
await axios.post(`${API_URL}/admin/v1/peers`, {
peerMultiaddrs: PEERS
});
// Test the REST API format push endpoint
try {
const restPushResponse = await axios.post(
`${API_URL}/lightpush/v1/message`,
{
pubsubTopic: "/waku/2/default-waku/proto",
message: {
contentTopic: "/test/1/message/proto",
payload: Array.from(
new TextEncoder().encode("Test message via REST endpoint")
)
}
}
);
expect(restPushResponse.status).toBe(200);
expect(restPushResponse.data.messageId).toBeDefined();
console.log("Message ID:", restPushResponse.data.messageId);
const infoRes = await axios.get(`${baseUrl}/waku/v1/peer-info`);
expect(infoRes.status).toBe(200);
expect(infoRes.data.peerId).toBeDefined();
expect(infoRes.data.multiaddrs).toBeDefined();
} catch (error) {
console.log("REST push might fail if no peers connected:", error);
}
});
test("can retrieve messages from the queue", async () => {
// Create and start a fresh node
await axios.post(`${API_URL}/admin/v1/create-node`, {
defaultBootstrap: true,
networkConfig: {
clusterId: 42,
shards: [0]
},
pubsubTopics: ["/waku/2/rs/42/0"] // Explicitly configure the pubsub topic
});
await axios.post(`${API_URL}/admin/v1/start-node`);
// FilterConnect to peers
await axios.post(`${API_URL}/admin/v1/peers`, {
peerMultiaddrs: PEERS
});
// Use a simple content topic to avoid encoding issues
const contentTopic = "test-queue";
try {
// Check endpoint existence by checking available routes
console.log("Checking server routes and status...");
const rootResponse = await axios.get(`${API_URL}/`);
console.log(
"Server root response:",
rootResponse.status,
rootResponse.data
);
// First ensure the queue is empty
console.log(`Attempting to get messages from ${contentTopic}...`);
const emptyQueueResponse = await axios.get(
`${API_URL}/filter/v1/messages/${contentTopic}`
);
expect(emptyQueueResponse.status).toBe(200);
expect(emptyQueueResponse.data.messages).toEqual([]);
} catch (error: any) {
console.error("Error accessing filter endpoint:", error.message);
if (error.response) {
console.error("Response status:", error.response.status);
console.error("Response data:", error.response.data);
}
throw error;
}
// Simulate adding messages to the queue
const messages = [
{
payload: Array.from(new TextEncoder().encode("Message 1")),
timestamp: Date.now() - 2000,
contentTopic
},
{
payload: Array.from(new TextEncoder().encode("Message 2")),
timestamp: Date.now() - 1000,
contentTopic
},
{
payload: Array.from(new TextEncoder().encode("Message 3")),
timestamp: Date.now(),
contentTopic
}
];
const testMessages = await axios.post(`${API_URL}/execute`, {
functionName: "simulateMessages",
params: [contentTopic, messages]
});
expect(testMessages.status).toBe(200);
// Now check if we can retrieve messages
const messagesResponse = await axios.get(
`${API_URL}/filter/v1/messages/${contentTopic}`
);
expect(messagesResponse.status).toBe(200);
expect(messagesResponse.data.messages.length).toBe(3);
// Verify message format
const message = messagesResponse.data.messages[0];
expect(message).toHaveProperty("payload");
expect(message).toHaveProperty("contentTopic");
expect(message).toHaveProperty("timestamp");
expect(message).toHaveProperty("version");
// Test pagination
const paginatedResponse = await axios.get(
`${API_URL}/filter/v1/messages/${contentTopic}?pageSize=2`
);
expect(paginatedResponse.status).toBe(200);
expect(paginatedResponse.data.messages.length).toBe(2);
// Test sorting order
const ascendingResponse = await axios.get(
`${API_URL}/filter/v1/messages/${contentTopic}?ascending=true`
);
expect(ascendingResponse.status).toBe(200);
expect(ascendingResponse.data.messages.length).toBe(3);
const timestamps = ascendingResponse.data.messages.map(
(msg: any) => msg.timestamp
);
expect(timestamps[0]).toBeLessThan(timestamps[1]);
expect(timestamps[1]).toBeLessThan(timestamps[2]);
});
test("can access filter endpoint for SSE", async () => {
// Create and start a fresh node - only if API is accessible
try {
// Quick check if server is running
await axios.get(API_URL, { timeout: 2000 });
// Create node
await axios.post(`${API_URL}/admin/v1/create-node`, {
defaultBootstrap: true,
networkConfig: {
clusterId: 42,
shards: [0]
},
pubsubTopics: ["/waku/2/rs/42/0"] // Explicitly configure the pubsub topic
});
// Start node
await axios.post(`${API_URL}/admin/v1/start-node`);
// FilterConnect to peers
await axios.post(`${API_URL}/admin/v1/peers`, {
peerMultiaddrs: PEERS
});
} catch (error) {
console.warn("Server appears to be unreachable, skipping test");
test.skip();
return;
}
const contentTopic = "test-sse";
// Verify filter endpoint is accessible
// Instead of implementing a full SSE client, we'll make sure the endpoint
// returns the correct headers and status code which indicates SSE readiness
try {
const sseResponse = await axios
.get(
`${API_URL}/filter/v2/messages/${contentTopic}?clusterId=42&shard=0`,
{
// Set a timeout to avoid hanging the test
timeout: 2000,
// Expecting the request to timeout as SSE keeps connection open
validateStatus: () => true,
// We can't use responseType: 'stream' directly with axios,
// but we can check the response headers
headers: {
Accept: "text/event-stream"
}
}
)
.catch((e) => {
// We expect a timeout error since SSE keeps connection open
if (e.code === "ECONNABORTED") {
return e.response;
}
throw e;
});
// If response exists and has expected SSE headers, the test passes
if (sseResponse) {
expect(sseResponse.headers["content-type"]).toBe("text/event-stream");
expect(sseResponse.headers["cache-control"]).toBe("no-cache");
expect(sseResponse.headers["connection"]).toBe("keep-alive");
} else {
// If no response, we manually make an HTTP request to check the headers
const headers = await new Promise<Record<string, string>>((resolve) => {
const requestUrl = new URL(
`${API_URL}/filter/v2/messages/${contentTopic}?clusterId=42&shard=0`
);
const req = http.get(requestUrl, (res) => {
// Only interested in headers
req.destroy();
if (res.headers) {
resolve(res.headers as Record<string, string>);
} else {
resolve({});
}
});
req.on("error", () => resolve({}));
});
if (Object.keys(headers).length === 0) {
console.warn(
"No headers received, SSE endpoint may not be accessible"
);
test.skip();
return;
}
expect(headers["content-type"]).toBe("text/event-stream");
}
} catch (error) {
console.error("Error during SSE endpoint test:", error);
test.fail();
return;
}
console.log("SSE endpoint is accessible with correct headers");
});
// Add a specific test just for the filter/v1/messages endpoint
test("can access filter/v1/messages endpoint directly", async () => {
// Check if server is available first
try {
await axios.get(API_URL, { timeout: 2000 });
} catch (error) {
console.warn("Server appears to be unreachable, skipping test");
test.skip();
return;
}
// Create a random content topic just for this test
const contentTopic = `direct-filter-${Date.now()}`;
try {
// Try different approaches to access the endpoint
console.log(
`Testing direct access to filter/v1/messages/${contentTopic}`
);
// Method 1: GET request with encoded content topic
const getResponse = await axios({
method: "get",
url: `${API_URL}/filter/v1/messages/${contentTopic}`,
validateStatus: function () {
// Allow any status code to check what's coming back
return true;
},
timeout: 5000
});
console.log("Response status:", getResponse.status);
console.log("Response headers:", getResponse.headers);
if (getResponse.status === 404) {
throw new Error(
`Endpoint not found (404): /filter/v1/messages/${contentTopic}`
);
}
// If we got here, the endpoint exists even if it returns empty results
expect(getResponse.status).toBe(200);
expect(getResponse.data).toHaveProperty("messages");
expect(Array.isArray(getResponse.data.messages)).toBe(true);
} catch (error: any) {
console.error("Error during filter/v1 endpoint test:", error.message);
if (error.response) {
console.error("Response status:", error.response.status);
console.error("Response headers:", error.response.headers);
console.error("Response data:", error.response.data);
} else if (error.request) {
console.error("No response received:", error.request);
// If no response, we'll skip the test rather than fail it
test.skip();
return;
}
throw error;
expect(error.response?.status).toBe(400);
}
});
});

View File

@ -1,35 +0,0 @@
export const NETWORK_CONFIG = {
"waku.sandbox": {
peers: [
"/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb",
"/dns4/node-01.gc-us-central1-a.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmRv1iQ3NoMMcjbtRmKxPuYBbF9nLYz2SDv9MTN8WhGuUU",
"/dns4/node-01.ac-cn-hongkong-c.waku.sandbox.status.im/tcp/30303/p2p/16Uiu2HAmQYiojgZ8APsh9wqbWNyCstVhnp9gbeNrxSEQnLJchC92"
]
},
"waku.test": {
peers: [
"/dns4/node-01.do-ams3.waku.test.status.im/tcp/8000/wss/p2p/16Uiu2HAkykgaECHswi3YKJ5dMLbq2kPVCo89fcyTd38UcQD6ej5W",
"/dns4/node-01.gc-us-central1-a.waku.test.status.im/tcp/8000/wss/p2p/16Uiu2HAmDCp8XJ9z1ev18zuv8NHekAsjNyezAvmMfFEJkiharitG",
"/dns4/node-01.ac-cn-hongkong-c.waku.test.status.im/tcp/8000/wss/p2p/16Uiu2HAkzHaTP5JsUwfR9NR8Rj9HC24puS6ocaU8wze4QrXr9iXp"
]
},
networkConfig: {
clusterId: 1,
shards: [0]
},
// Default node configuration
defaultNodeConfig: {
defaultBootstrap: false
},
// Test message configuration
testMessage: {
contentTopic: "/test/1/message/proto",
payload: "Hello, Waku!"
}
};
export const ACTIVE_PEERS = NETWORK_CONFIG["waku.test"].peers;

View File

@ -0,0 +1,128 @@
import axios from "axios";
import { GenericContainer, StartedTestContainer } from "testcontainers";
import { Logger } from "@waku/utils";
const log = new Logger("container-helpers");
export interface ContainerSetupOptions {
environment?: Record<string, string>;
networkMode?: string;
timeout?: number;
maxAttempts?: number;
}
export interface ContainerSetupResult {
container: StartedTestContainer;
baseUrl: string;
}
/**
* Starts a waku-browser-tests Docker container with proper health checking.
* Follows patterns from @waku/tests package for retry logic and cleanup.
*/
export async function startBrowserTestsContainer(
options: ContainerSetupOptions = {}
): Promise<ContainerSetupResult> {
const {
environment = {},
networkMode = "bridge",
timeout = 2000,
maxAttempts = 60
} = options;
log.info("Starting waku-browser-tests container...");
let generic = new GenericContainer("waku-browser-tests:local")
.withExposedPorts(8080)
.withNetworkMode(networkMode);
// Apply environment variables
for (const [key, value] of Object.entries(environment)) {
generic = generic.withEnvironment({ [key]: value });
}
const container = await generic.start();
// Set up container logging - stream all output from the start
const logs = await container.logs();
logs.on("data", (b) => process.stdout.write("[container] " + b.toString()));
logs.on("error", (err) => log.error("[container log error]", err));
// Give container time to initialize
await new Promise((r) => setTimeout(r, 5000));
const mappedPort = container.getMappedPort(8080);
const baseUrl = `http://127.0.0.1:${mappedPort}`;
// Wait for server readiness with retry logic (following waku/tests patterns)
const serverReady = await waitForServerReady(baseUrl, maxAttempts, timeout);
if (!serverReady) {
await logFinalContainerState(container);
throw new Error("Container failed to become ready");
}
log.info("✅ Browser tests container ready");
await new Promise((r) => setTimeout(r, 500)); // Final settling time
return { container, baseUrl };
}
/**
* Waits for server to become ready with exponential backoff and detailed logging.
* Follows retry patterns from @waku/tests ServiceNode.
*/
async function waitForServerReady(
baseUrl: string,
maxAttempts: number,
timeout: number
): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await axios.get(`${baseUrl}/`, { timeout });
if (res.status === 200) {
log.info(`Server is ready after ${i + 1} attempts`);
return true;
}
} catch (error) {
if (i % 10 === 0) {
log.info(`Attempt ${i + 1}/${maxAttempts} failed:`, error.code || error.message);
}
}
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
/**
* Logs final container state for debugging, following waku/tests error handling patterns.
*/
async function logFinalContainerState(container: StartedTestContainer): Promise<void> {
try {
const finalLogs = await container.logs({ tail: 50 });
log.info("=== Final Container Logs ===");
finalLogs.on("data", (b) => log.info(b.toString()));
await new Promise((r) => setTimeout(r, 1000));
} catch (logError) {
log.error("Failed to get container logs:", logError);
}
}
/**
* Gracefully stops containers with retry logic, following teardown patterns from waku/tests.
*/
export async function stopContainer(container: StartedTestContainer): Promise<void> {
if (!container) return;
log.info("Stopping container gracefully...");
try {
await container.stop({ timeout: 10000 });
log.info("Container stopped successfully");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(
"Container stop had issues (expected):",
message
);
}
}

View File

@ -0,0 +1,8 @@
/**
* Shared test utilities for browser-tests package.
* Follows patterns established in @waku/tests package.
*/
export * from "./container-helpers.js";
export * from "./nwaku-helpers.js";
export * from "./test-config.js";

View File

@ -0,0 +1,141 @@
import { ServiceNode } from "@waku/tests";
import { DefaultTestRoutingInfo } from "@waku/tests";
import { Logger } from "@waku/utils";
const log = new Logger("nwaku-helpers");
export interface TwoNodeNetwork {
nodes: ServiceNode[];
}
/**
* Creates a two-node nwaku network following waku/tests patterns.
* Node 1: Relay + Light Push (service provider)
* Node 2: Relay only (network peer)
*/
export async function createTwoNodeNetwork(): Promise<TwoNodeNetwork> {
log.info("Creating nwaku node 1 (Relay + Light Push)...");
const lightPushNode = new ServiceNode(
"lightpush-node-" + Math.random().toString(36).substring(7),
);
const lightPushArgs = {
relay: true,
lightpush: true,
filter: false,
store: false,
clusterId: DefaultTestRoutingInfo.clusterId,
numShardsInNetwork: DefaultTestRoutingInfo.networkConfig.numShardsInCluster,
contentTopic: [DefaultTestRoutingInfo.contentTopic],
};
await lightPushNode.start(lightPushArgs, { retries: 3 });
log.info("Creating nwaku node 2 (Relay only)...");
const relayNode = new ServiceNode(
"relay-node-" + Math.random().toString(36).substring(7),
);
// Connect second node to first node (following ServiceNodesFleet pattern)
const firstNodeAddr = await lightPushNode.getExternalMultiaddr();
const relayArgs = {
relay: true,
lightpush: false,
filter: false,
store: false,
staticnode: firstNodeAddr,
clusterId: DefaultTestRoutingInfo.clusterId,
numShardsInNetwork: DefaultTestRoutingInfo.networkConfig.numShardsInCluster,
contentTopic: [DefaultTestRoutingInfo.contentTopic],
};
await relayNode.start(relayArgs, { retries: 3 });
// Wait for network formation (following waku/tests timing patterns)
log.info("Waiting for nwaku network formation...");
await new Promise((r) => setTimeout(r, 5000));
// Verify connectivity (optional, for debugging)
await verifyNetworkFormation([lightPushNode, relayNode]);
return {
nodes: [lightPushNode, relayNode],
};
}
/**
* Verifies that nwaku nodes have formed connections.
* Follows error handling patterns from waku/tests.
*/
async function verifyNetworkFormation(nodes: ServiceNode[]): Promise<void> {
try {
const peerCounts = await Promise.all(
nodes.map(async (node, index) => {
const peers = await node.peers();
log.info(`Node ${index + 1} has ${peers.length} peer(s)`);
return peers.length;
}),
);
if (peerCounts.every((count) => count === 0)) {
log.warn("⚠️ Nodes may not be properly connected yet");
}
} catch (error) {
log.warn("Could not verify peer connections:", error);
}
}
/**
* Extracts Docker-accessible multiaddr from nwaku node.
* Returns multiaddr using container's internal IP for Docker network communication.
*/
export async function getDockerAccessibleMultiaddr(
node: ServiceNode,
): Promise<string> {
// Get multiaddr with localhost and extract components
const localhostMultiaddr = await node.getMultiaddrWithId();
const peerId = await node.getPeerId();
// Extract port from multiaddr string
const multiaddrStr = localhostMultiaddr.toString();
const portMatch = multiaddrStr.match(/\/tcp\/(\d+)/);
const port = portMatch ? portMatch[1] : null;
if (!port) {
throw new Error("Could not extract port from multiaddr: " + multiaddrStr);
}
// Get Docker container IP (accessing internal field)
// Note: This accesses an internal implementation detail of ServiceNode
const nodeWithDocker = node as ServiceNode & {
docker?: { containerIp?: string };
};
const containerIp = nodeWithDocker.docker?.containerIp;
if (!containerIp) {
throw new Error("Could not get container IP from node");
}
// Build Docker network accessible multiaddr
const dockerMultiaddr = `/ip4/${containerIp}/tcp/${port}/ws/p2p/${peerId}`;
log.info("Original multiaddr:", multiaddrStr);
log.info("Docker accessible multiaddr:", dockerMultiaddr);
return dockerMultiaddr;
}
/**
* Stops nwaku nodes with retry logic, following teardown patterns from waku/tests.
*/
export async function stopNwakuNodes(nodes: ServiceNode[]): Promise<void> {
if (!nodes || nodes.length === 0) return;
log.info("Stopping nwaku nodes...");
try {
await Promise.all(nodes.map((node) => node.stop()));
log.info("Nwaku nodes stopped successfully");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn("Nwaku nodes stop had issues:", message);
}
}

View File

@ -0,0 +1,127 @@
import { expect } from "@playwright/test";
import { DefaultTestRoutingInfo } from "@waku/tests";
import { AxiosResponse } from "axios";
/**
* Response type definitions for API endpoints
*/
interface ServerHealthResponse {
status: string;
}
interface PeerInfoResponse {
peerId: string;
multiaddrs: string[];
peers: string[];
}
interface LightPushV3Result {
successes: string[];
failures: Array<{ error: string; peerId?: string }>;
}
interface LightPushV3Response {
success: boolean;
result: LightPushV3Result;
error?: string;
}
interface MessageResponse {
contentTopic: string;
payload: string;
version: number;
timestamp?: bigint | number;
}
/**
* Common test configuration constants following waku/tests patterns.
*/
export const TEST_CONFIG = {
// Test timeouts (following waku/tests timeout patterns)
DEFAULT_TEST_TIMEOUT: 120000, // 2 minutes
CONTAINER_READY_TIMEOUT: 60000, // 1 minute
NETWORK_FORMATION_DELAY: 5000, // 5 seconds
SUBSCRIPTION_DELAY: 3000, // 3 seconds
MESSAGE_PROPAGATION_DELAY: 5000, // 5 seconds
WAKU_INIT_DELAY: 8000, // 8 seconds
// Network configuration
DEFAULT_CLUSTER_ID: DefaultTestRoutingInfo.clusterId.toString(),
DEFAULT_CONTENT_TOPIC: "/test/1/browser-tests/proto",
// Test messages
DEFAULT_TEST_MESSAGE: "Hello from browser tests",
} as const;
/**
* Environment variable builders for different test scenarios.
*/
export const ENV_BUILDERS = {
/**
* Environment for production ENR bootstrap (integration test pattern).
*/
withProductionEnr: () => ({
WAKU_ENR_BOOTSTRAP: "enr:-QEnuEBEAyErHEfhiQxAVQoWowGTCuEF9fKZtXSd7H_PymHFhGJA3rGAYDVSHKCyJDGRLBGsloNbS8AZF33IVuefjOO6BIJpZIJ2NIJpcIQS39tkim11bHRpYWRkcnO4lgAvNihub2RlLTAxLmRvLWFtczMud2FrdXYyLnRlc3Quc3RhdHVzaW0ubmV0BgG73gMAODcxbm9kZS0wMS5hYy1jbi1ob25na29uZy1jLndha3V2Mi50ZXN0LnN0YXR1c2ltLm5ldAYBu94DACm9A62t7AQL4Ef5ZYZosRpQTzFVAB8jGjf1TER2wH-0zBOe1-MDBNLeA4lzZWNwMjU2azGhAzfsxbxyCkgCqq8WwYsVWH7YkpMLnU2Bw5xJSimxKav-g3VkcIIjKA",
WAKU_CLUSTER_ID: "1",
}),
/**
* Environment for local nwaku node connection (e2e test pattern).
*/
withLocalLightPush: (lightpushMultiaddr: string) => ({
WAKU_LIGHTPUSH_NODE: lightpushMultiaddr,
WAKU_CLUSTER_ID: TEST_CONFIG.DEFAULT_CLUSTER_ID,
}),
};
/**
* Test assertion helpers following waku/tests verification patterns.
*/
export const ASSERTIONS = {
/**
* Verifies server health response structure.
*/
serverHealth: (response: AxiosResponse<ServerHealthResponse>) => {
expect(response.status).toBe(200);
expect(response.data.status).toBe("Waku simulation server is running");
},
/**
* Verifies peer info response structure.
*/
peerInfo: (response: AxiosResponse<PeerInfoResponse>) => {
expect(response.status).toBe(200);
expect(response.data.peerId).toBeDefined();
expect(typeof response.data.peerId).toBe("string");
},
/**
* Verifies lightpush response structure (v3 format).
*/
lightPushV3Success: (response: AxiosResponse<LightPushV3Response>) => {
expect(response.status).toBe(200);
expect(response.data).toHaveProperty('success', true);
expect(response.data).toHaveProperty('result');
expect(response.data.result).toHaveProperty('successes');
expect(Array.isArray(response.data.result.successes)).toBe(true);
expect(response.data.result.successes.length).toBeGreaterThan(0);
},
/**
* Verifies message content and structure.
*/
messageContent: (message: MessageResponse, expectedContent: string, expectedTopic: string) => {
expect(message).toHaveProperty('contentTopic', expectedTopic);
expect(message).toHaveProperty('payload');
expect(typeof message.payload).toBe('string');
const receivedPayload = Buffer.from(message.payload, 'base64').toString();
expect(receivedPayload).toBe(expectedContent);
// Optional fields
expect(message).toHaveProperty('version');
if (message.timestamp) {
expect(['bigint', 'number']).toContain(typeof message.timestamp);
}
},
};

View File

@ -15,5 +15,5 @@
"typeRoots": ["./node_modules/@types", "./types"]
},
"include": ["src/server.ts", "types/**/*.d.ts"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "web"]
}

View File

@ -1,27 +1,19 @@
import { LightNode } from "@waku/sdk";
import { IWakuNode } from "../src/api/common.js";
import {
createWakuNode,
dialPeers,
getDebugInfo,
getPeerInfo,
pushMessage,
subscribe
} from "../src/api/shared.js";
import type { WakuHeadless } from "../web/index.js";
export interface WindowNetworkConfig {
clusterId?: number;
shards?: number[];
}
export interface ITestBrowser extends Window {
wakuApi: WakuHeadless;
__WAKU_NETWORK_CONFIG?: WindowNetworkConfig;
__WAKU_LIGHTPUSH_NODE?: string | null;
__WAKU_ENR_BOOTSTRAP?: string | null;
}
// Define types for the Waku node and window
declare global {
// eslint-disable-next-line no-unused-vars
interface Window {
waku: IWakuNode & LightNode;
wakuAPI: {
getPeerInfo: typeof getPeerInfo;
getDebugInfo: typeof getDebugInfo;
pushMessage: typeof pushMessage;
dialPeers: typeof dialPeers;
createWakuNode: typeof createWakuNode;
subscribe: typeof subscribe;
[key: string]: any;
};
wakuApi: WakuHeadless;
}
}

View File

@ -1,7 +1,9 @@
declare module "serve" {
import type { Server } from "http";
function serve(
folder: string,
options: { port: number; single: boolean; listen: boolean }
): any;
options: { port: number; single: boolean; listen: boolean },
): Promise<Server>;
export default serve;
}

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Waku Test Environment</title>
</head>
<body>
<h1>Waku Test Environment</h1>
<script type="module" src="./index.js"></script>
</body>
</html>

View File

@ -0,0 +1,431 @@
import {
createLightNode,
LightNode,
Protocols,
NetworkConfig,
CreateNodeOptions,
} from "@waku/sdk";
import {
AutoSharding,
DEFAULT_CLUSTER_ID,
DEFAULT_NUM_SHARDS,
ShardId,
StaticSharding,
ShardInfo,
CreateLibp2pOptions,
IEncoder,
ILightPush,
SDKProtocolResult,
Failure,
} from "@waku/interfaces";
import { bootstrap } from "@libp2p/bootstrap";
import { EnrDecoder, TransportProtocol } from "@waku/enr";
import type { Multiaddr } from "@multiformats/multiaddr";
import type { ITestBrowser } from "../types/global.js";
import { Logger, StaticShardingRoutingInfo } from "@waku/utils";
import type { PeerId } from "@libp2p/interface";
const log = new Logger("waku-headless");
export interface SerializableSDKProtocolResult {
successes: string[];
failures: Array<{
error: string;
peerId?: string;
}>;
myPeerId?: string;
}
function makeSerializable(result: SDKProtocolResult): SerializableSDKProtocolResult {
return {
...result,
successes: result.successes.map((peerId: PeerId) => peerId.toString()),
failures: result.failures.map((failure: Failure) => ({
error: failure.error || failure.toString(),
peerId: failure.peerId ? failure.peerId.toString() : undefined,
})),
};
}
async function convertEnrToMultiaddrs(enrString: string): Promise<string[]> {
try {
const enr = await EnrDecoder.fromString(enrString);
const allMultiaddrs = enr.getAllLocationMultiaddrs();
const multiaddrs: string[] = [];
for (const multiaddr of allMultiaddrs) {
const maStr = multiaddr.toString();
multiaddrs.push(maStr);
}
if (multiaddrs.length === 0) {
const tcpMultiaddr = enr.getFullMultiaddr(TransportProtocol.TCP);
if (tcpMultiaddr) {
const tcpStr = tcpMultiaddr.toString();
multiaddrs.push(tcpStr);
}
const udpMultiaddr = enr.getFullMultiaddr(TransportProtocol.UDP);
if (udpMultiaddr) {
const udpStr = udpMultiaddr.toString();
multiaddrs.push(udpStr);
}
}
return multiaddrs;
} catch (error) {
return [];
}
}
export class WakuHeadless {
waku: LightNode | null;
networkConfig: NetworkConfig;
lightpushNode: string | null;
enrBootstrap: string | null;
constructor(
networkConfig?: Partial<NetworkConfig>,
lightpushNode?: string | null,
enrBootstrap?: string | null,
) {
this.waku = null;
this.networkConfig = this.buildNetworkConfig(networkConfig);
log.info("Network config on construction:", this.networkConfig);
this.lightpushNode = lightpushNode || null;
this.enrBootstrap = enrBootstrap || null;
if (this.lightpushNode) {
log.info(`Configured preferred lightpush node: ${this.lightpushNode}`);
}
if (this.enrBootstrap) {
log.info(`Configured ENR bootstrap: ${this.enrBootstrap}`);
}
}
private shouldUseCustomBootstrap(options: CreateNodeOptions): boolean {
const hasEnr = Boolean(this.enrBootstrap);
const isDefaultBootstrap = Boolean(options.defaultBootstrap);
return hasEnr && !isDefaultBootstrap;
}
private async getBootstrapMultiaddrs(): Promise<string[]> {
if (!this.enrBootstrap) {
return [];
}
const enrList = this.enrBootstrap.split(",").map((enr) => enr.trim());
const allMultiaddrs: string[] = [];
for (const enr of enrList) {
const multiaddrs = await convertEnrToMultiaddrs(enr);
if (multiaddrs.length > 0) {
allMultiaddrs.push(...multiaddrs);
}
}
return allMultiaddrs;
}
private buildNetworkConfig(
providedConfig?: Partial<NetworkConfig> | Partial<ShardInfo>,
): NetworkConfig {
const clusterId = providedConfig?.clusterId ?? DEFAULT_CLUSTER_ID;
const staticShards = (providedConfig as Partial<ShardInfo>)?.shards;
if (
staticShards &&
Array.isArray(staticShards) &&
staticShards.length > 0
) {
log.info("Using static sharding with shards:", staticShards);
return {
clusterId,
} as StaticSharding;
}
const numShardsInCluster =
(providedConfig as Partial<AutoSharding>)?.numShardsInCluster ?? DEFAULT_NUM_SHARDS;
log.info(
"Using auto sharding with num shards in cluster:",
numShardsInCluster,
);
return {
clusterId,
numShardsInCluster,
} as AutoSharding;
}
private async send(
lightPush: ILightPush,
encoder: IEncoder,
payload: Uint8Array,
): Promise<SDKProtocolResult> {
return lightPush.send(encoder, {
payload,
timestamp: new Date(),
});
}
async pushMessageV3(
contentTopic: string,
payload: string,
pubsubTopic: string,
): Promise<SerializableSDKProtocolResult> {
if (!this.waku) {
throw new Error("Waku node not started");
}
log.info(
"Pushing message via v3 lightpush:",
contentTopic,
payload,
pubsubTopic,
);
log.info("Waku node:", this.waku);
log.info("Network config:", this.networkConfig);
let processedPayload: Uint8Array;
try {
const binaryString = atob(payload);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
processedPayload = bytes;
} catch (e) {
processedPayload = new TextEncoder().encode(payload);
}
try {
const lightPush = this.waku.lightPush;
if (!lightPush) {
throw new Error("Lightpush service not available");
}
let shardId: ShardId | undefined;
if (pubsubTopic) {
const staticShardingRoutingInfo =
StaticShardingRoutingInfo.fromPubsubTopic(
pubsubTopic,
this.networkConfig as StaticSharding,
);
shardId = staticShardingRoutingInfo?.shardId;
}
const encoder = this.waku.createEncoder({
contentTopic,
shardId,
});
log.info("Encoder:", encoder);
log.info("Pubsub topic:", pubsubTopic);
log.info("Encoder pubsub topic:", encoder.pubsubTopic);
if (pubsubTopic && pubsubTopic !== encoder.pubsubTopic) {
log.warn(
`Explicit pubsubTopic ${pubsubTopic} provided, but auto-sharding determined ${encoder.pubsubTopic}. Using auto-sharding.`,
);
}
let result;
if (this.lightpushNode) {
try {
const preferredPeerId = this.getPeerIdFromMultiaddr(
this.lightpushNode,
);
if (preferredPeerId) {
result = await this.send(lightPush, encoder, processedPayload);
log.info("✅ Message sent via preferred lightpush node");
} else {
throw new Error(
"Could not extract peer ID from preferred node address",
);
}
} catch (error) {
log.error(
"Couldn't send message via preferred lightpush node:",
error,
);
result = await this.send(lightPush, encoder, processedPayload);
}
} else {
result = await this.send(lightPush, encoder, processedPayload);
}
const serializableResult = makeSerializable(result);
return serializableResult;
} catch (error) {
log.error("Error sending message via v3 lightpush:", error);
throw new Error(
`Failed to send v3 message: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async waitForPeers(
timeoutMs: number = 10000,
protocols: Protocols[] = [Protocols.LightPush, Protocols.Filter],
) {
if (!this.waku) {
throw new Error("Waku node not started");
}
const startTime = Date.now();
try {
await this.waku.waitForPeers(protocols, timeoutMs);
const elapsed = Date.now() - startTime;
const peers = this.waku.libp2p.getPeers();
return {
success: true,
peersFound: peers.length,
protocolsRequested: protocols,
timeElapsed: elapsed,
};
} catch (error) {
const elapsed = Date.now() - startTime;
log.error(`Failed to find peers after ${elapsed}ms:`, error);
throw error;
}
}
async createWakuNode(options: CreateNodeOptions) {
try {
if (this.waku) {
await this.waku.stop();
}
} catch (e) {
log.warn("ignore previous waku stop error");
}
let libp2pConfig: CreateLibp2pOptions = {
...options.libp2p,
filterMultiaddrs: false,
};
if (this.enrBootstrap) {
const multiaddrs = await this.getBootstrapMultiaddrs();
if (multiaddrs.length > 0) {
libp2pConfig.peerDiscovery = [
bootstrap({ list: multiaddrs }),
...(options.libp2p?.peerDiscovery || []),
];
}
}
const createOptions = {
...options,
networkConfig: this.networkConfig,
libp2p: libp2pConfig,
};
this.waku = await createLightNode(createOptions);
return { success: true };
}
async startNode() {
if (!this.waku) {
throw new Error("Waku node not created");
}
await this.waku.start();
if (this.lightpushNode) {
await this.dialPreferredLightpushNode();
}
return { success: true };
}
private async dialPreferredLightpushNode() {
if (!this.waku || !this.lightpushNode) {
log.info("Skipping dial: waku or lightpushNode not set");
return;
}
try {
log.info("Attempting to dial preferred lightpush node:", this.lightpushNode);
await this.waku.dial(this.lightpushNode);
log.info("Successfully dialed preferred lightpush node:", this.lightpushNode);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(
"Failed to dial preferred lightpush node:",
this.lightpushNode,
message
);
}
}
private getPeerIdFromMultiaddr(multiaddr: string): string | null {
const parts = multiaddr.split("/");
const p2pIndex = parts.indexOf("p2p");
return p2pIndex !== -1 && p2pIndex + 1 < parts.length
? parts[p2pIndex + 1]
: null;
}
async stopNode() {
if (!this.waku) {
throw new Error("Waku node not created");
}
await this.waku.stop();
return { success: true };
}
getPeerInfo() {
if (!this.waku) {
throw new Error("Waku node not started");
}
const addrs = this.waku.libp2p.getMultiaddrs();
return {
peerId: this.waku.libp2p.peerId.toString(),
multiaddrs: addrs.map((a: Multiaddr) => a.toString()),
peers: [],
};
}
}
(() => {
try {
log.info("Initializing WakuHeadless...");
const testWindow = window as ITestBrowser;
const globalNetworkConfig = testWindow.__WAKU_NETWORK_CONFIG;
const globalLightpushNode = testWindow.__WAKU_LIGHTPUSH_NODE;
const globalEnrBootstrap = testWindow.__WAKU_ENR_BOOTSTRAP;
log.info("Global config from window:", {
networkConfig: globalNetworkConfig,
lightpushNode: globalLightpushNode,
enrBootstrap: globalEnrBootstrap
});
const instance = new WakuHeadless(
globalNetworkConfig,
globalLightpushNode,
globalEnrBootstrap,
);
testWindow.wakuApi = instance;
log.info("WakuHeadless initialized successfully:", !!testWindow.wakuApi);
} catch (error) {
log.error("Error initializing WakuHeadless:", error);
const testWindow = window as ITestBrowser;
// Create a stub wakuApi that will reject all method calls
testWindow.wakuApi = {
waku: null,
networkConfig: { clusterId: 0, numShardsInCluster: 0 },
lightpushNode: null,
enrBootstrap: null,
error,
createWakuNode: () => Promise.reject(new Error("WakuHeadless failed to initialize")),
startNode: () => Promise.reject(new Error("WakuHeadless failed to initialize")),
stopNode: () => Promise.reject(new Error("WakuHeadless failed to initialize")),
pushMessageV3: () => Promise.reject(new Error("WakuHeadless failed to initialize")),
waitForPeers: () => Promise.reject(new Error("WakuHeadless failed to initialize")),
getPeerInfo: () => { throw new Error("WakuHeadless failed to initialize"); },
} as unknown as WakuHeadless;
}
})();

View File

@ -9,7 +9,6 @@ import {
WakuEvent
} from "@waku/interfaces";
import { Logger } from "@waku/utils";
import { numberToBytes } from "@waku/utils/bytes";
import { Dialer } from "./dialer.js";
import { NetworkMonitor } from "./network_monitor.js";
@ -125,7 +124,6 @@ export class ConnectionLimiter implements IConnectionLimiter {
private async maintainConnections(): Promise<void> {
await this.maintainConnectionsCount();
await this.maintainBootstrapConnections();
await this.maintainTTLConnectedPeers();
}
private async onDisconnectedEvent(): Promise<void> {
@ -215,28 +213,6 @@ export class ConnectionLimiter implements IConnectionLimiter {
}
}
private async maintainTTLConnectedPeers(): Promise<void> {
log.info(`Maintaining TTL connected peers`);
const promises = this.libp2p.getConnections().map(async (c) => {
try {
await this.libp2p.peerStore.merge(c.remotePeer, {
metadata: {
ttl: numberToBytes(Date.now())
}
});
log.info(`TTL updated for connected peer ${c.remotePeer.toString()}`);
} catch (error) {
log.error(
`Unexpected error while maintaining TTL connected peer`,
error
);
}
});
await Promise.all(promises);
}
private async dialPeersFromStore(): Promise<void> {
log.info(`Dialing peers from store`);
@ -268,6 +244,9 @@ export class ConnectionLimiter implements IConnectionLimiter {
private async getPrioritizedPeers(): Promise<Peer[]> {
const allPeers = await this.libp2p.peerStore.all();
const allConnections = this.libp2p.getConnections();
const allConnectionsSet = new Set(
allConnections.map((c) => c.remotePeer.toString())
);
log.info(
`Found ${allPeers.length} peers in store, and found ${allConnections.length} connections`
@ -275,7 +254,7 @@ export class ConnectionLimiter implements IConnectionLimiter {
const notConnectedPeers = allPeers.filter(
(p) =>
!allConnections.some((c) => c.remotePeer.equals(p.id)) &&
!allConnectionsSet.has(p.id.toString()) &&
isAddressesSupported(
this.libp2p,
p.addresses.map((a) => a.multiaddr)

View File

@ -1,34 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
es2021: true
},
plugins: ["import"],
extends: ["eslint:recommended"],
parserOptions: {
ecmaVersion: 2022,
sourceType: "module"
},
rules: {
// Disable rules that might cause issues with this package
"no-undef": "off"
},
ignorePatterns: [
"node_modules",
"build",
"coverage"
],
overrides: [
{
files: ["*.spec.ts", "**/test_utils/*.ts", "*.js", "*.cjs"],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"no-console": "off",
"import/no-extraneous-dependencies": ["error", { "devDependencies": true }]
}
}
]
};

View File

@ -1,23 +0,0 @@
# Waku Headless Tests
This package contains a minimal browser application used for testing the Waku SDK in a browser environment. It is used by the browser-tests package to run end-to-end tests on the SDK.
## Usage
### Build the app
```bash
npm run build
```
### Start the app
```bash
npm start
```
This will start a server on port 8080 by default.
## Integration with browser-tests
This package is designed to be used with the browser-tests package to run end-to-end tests on the SDK. It exposes the Waku API via a global object in the browser.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,50 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Headless</title>
<link rel="stylesheet" href="./style.css" />
<link rel="apple-touch-icon" href="./favicon.png" />
<link rel="manifest" href="./manifest.json" />
<link rel="icon" href="./favicon.ico" />
</head>
<body>
<div id="state"></div>
<div class="content">
<div class="header">
<h3>Status: <span id="status"></span></h3>
<details>
<summary>Peer's information</summary>
<h4>Content topic</h4>
<p id="contentTopic"></p>
<h4>Local Peer Id</h4>
<p id="localPeerId"></p>
<h4>Remote Peer Id</h4>
<p id="remotePeerId"></p>
</details>
</div>
<div id="messages"></div>
<div class="footer">
<div class="inputArea">
<input type="text" id="nickText" placeholder="Nickname" />
<textarea id="messageText" placeholder="Message"></textarea>
</div>
<div class="controls">
<button id="send">Send</button>
<button id="exit">Exit chat</button>
</div>
</div>
</div>
<script src="./build/bundle.js"></script>
</body>
</html>

View File

@ -1,14 +0,0 @@
/* eslint-disable */
import { API } from "../browser-tests/src/api/shared.ts";
runApp().catch((err) => {
console.error(err);
});
async function runApp() {
if (typeof window !== "undefined") {
// Expose shared API functions for browser communication
window.wakuAPI = API;
window.subscriptions = [];
}
}

View File

@ -1,19 +0,0 @@
{
"name": "Light Chat",
"description": "Send messages between several users (or just one) using light client targeted protocols.",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "favicon.png",
"type": "image/png",
"sizes": "192x192"
}
],
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff"
}

View File

@ -1,27 +0,0 @@
{
"name": "@waku/headless-tests",
"version": "0.1.0",
"private": true,
"homepage": "/headless",
"type": "module",
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.23.3",
"babel-loader": "^9.1.3",
"filter-obj": "^2.0.2",
"it-first": "^3.0.9",
"node-polyfill-webpack-plugin": "^2.0.1",
"serve": "^14.1.2",
"webpack": "^5.99.5",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@waku/sdk": "^0.0.30"
},
"scripts": {
"start": "serve .",
"build": "webpack",
"format": "eslint --fix webpack.config.js"
}
}

View File

@ -1,153 +0,0 @@
* {
margin: 0;
padding: 0;
word-wrap: break-word;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
html {
font-size: 16px;
overflow: hidden;
}
body {
display: flex;
align-items: center;
padding: 10px;
justify-content: center;
}
details {
margin-bottom: 15px;
}
details p {
margin-bottom: 10px;
}
summary {
cursor: pointer;
max-width: 100%;
margin-bottom: 5px;
}
span {
font-weight: 300;
}
input, textarea {
line-height: 1rem;
padding: 5px;
}
textarea {
min-height: 3rem;
}
h3 {
margin-bottom: 5px;
}
.content {
width: 800px;
min-width: 300px;
max-width: 800px;
height: 100%;
display: flex;
flex-direction: column;
align-content: space-between;
}
#messages {
overflow-y: scroll;
overflow-x: hidden;
}
.message + .message {
margin-top: 15px;
}
.message :first-child {
font-weight: bold;
}
.message p + p {
margin-top: 5px;
}
.message span {
font-size: 0.8rem;
}
.inputArea {
display: flex;
gap: 10px;
flex-direction: column;
margin-top: 20px;
}
.controls {
margin-top: 10px;
display: flex;
gap: 10px;
}
.controls button {
flex-grow: 1;
cursor: pointer;
padding: 10px;
}
#send {
background-color: #32d1a0;
border: none;
color: white;
}
#send:hover {
background-color: #3abd96;
}
#send:active {
background-color: #3ba183;
}
#exit {
color: white;
border: none;
background-color: #ff3a31;
}
#exit:hover {
background-color: #e4423a;
}
#exit:active {
background-color: #c84740;
}
.success {
color: #3ba183;
}
.progress {
color: #9ea13b;
}
.terminated {
color: black;
}
.error {
color: #c84740;
}
.footer {
display: flex;
width: 100%;
flex-direction: column;
align-self: flex-end;
}

View File

@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"allowJs": true,
"checkJs": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"**/*.js"
]
}

View File

@ -1,47 +0,0 @@
/* eslint-disable */
/**
* This webpack configuration file uses ES Module syntax.
*/
import path from 'path';
import { fileURLToPath } from 'url';
import NodePolyfillPlugin from 'node-polyfill-webpack-plugin';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: "./index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "build")
},
mode: "production",
target: "web",
plugins: [new NodePolyfillPlugin()],
resolve: {
extensions: [".js", ".ts", ".tsx", ".jsx"],
fallback: {
fs: false,
net: false,
tls: false
},
alias: {
// Create an alias to easily import from src
"@src": path.resolve(__dirname, "../src")
}
},
module: {
rules: [
{
test: /\.(js|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-typescript"]
}
}
}
]
}
};

View File

@ -84,7 +84,7 @@ export interface SdsMessage {
senderId: string
messageId: string
channelId: string
lamportTimestamp?: number
lamportTimestamp?: bigint
causalHistory: HistoryEntry[]
bloomFilter?: Uint8Array
content?: Uint8Array
@ -117,7 +117,7 @@ export namespace SdsMessage {
if (obj.lamportTimestamp != null) {
w.uint32(80)
w.int32(obj.lamportTimestamp)
w.uint64(obj.lamportTimestamp)
}
if (obj.causalHistory != null) {
@ -167,7 +167,7 @@ export namespace SdsMessage {
break
}
case 10: {
obj.lamportTimestamp = reader.int32()
obj.lamportTimestamp = reader.uint64()
break
}
case 11: {

View File

@ -9,7 +9,7 @@ message SdsMessage {
string sender_id = 1; // Participant ID of the message sender
string message_id = 2; // Unique identifier of the message
string channel_id = 3; // Identifier of the channel to which the message belongs
optional int32 lamport_timestamp = 10; // Logical timestamp for causal ordering in channel
optional uint64 lamport_timestamp = 10; // Logical timestamp for causal ordering in channel
repeated HistoryEntry causal_history = 11; // List of preceding message IDs that this message causally depends on. Generally 2 or 3 message IDs are included.
optional bytes bloom_filter = 12; // Bloom filter representing received message IDs in channel
optional bytes content = 20; // Actual content of the message

View File

@ -79,7 +79,7 @@
"@waku/core": "^0.0.39",
"@waku/utils": "^0.0.27",
"@noble/hashes": "^1.2.0",
"@waku/zerokit-rln-wasm": "^0.0.13",
"@waku/zerokit-rln-wasm": "^0.2.1",
"ethereum-cryptography": "^3.1.0",
"ethers": "^5.7.2",
"lodash": "^4.17.21",

View File

@ -1,363 +0,0 @@
import { createDecoder, createEncoder } from "@waku/core/lib/message/version_0";
import { IDecodedMessage } from "@waku/interfaces";
import {
generatePrivateKey,
generateSymmetricKey,
getPublicKey
} from "@waku/message-encryption";
import {
createDecoder as createAsymDecoder,
createEncoder as createAsymEncoder
} from "@waku/message-encryption/ecies";
import {
createDecoder as createSymDecoder,
createEncoder as createSymEncoder
} from "@waku/message-encryption/symmetric";
import { expect } from "chai";
import {
createRLNDecoder,
createRLNEncoder,
RLNDecoder,
RLNEncoder
} from "./codec.js";
import {
createTestMetaSetter,
createTestRLNCodecSetup,
EMPTY_PROTO_MESSAGE,
TEST_CONSTANTS,
verifyRLNMessage
} from "./codec.test-utils.js";
import { RlnMessage } from "./message.js";
import { epochBytesToInt } from "./utils/index.js";
describe("RLN codec with version 0", () => {
it("toWire", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const rlnEncoder = createRLNEncoder({
encoder: createEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo
}),
rlnInstance,
index,
credential
});
const rlnDecoder = createRLNDecoder({
rlnInstance,
decoder: createDecoder(
TEST_CONSTANTS.contentTopic,
TEST_CONSTANTS.routingInfo
)
});
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
protoResult!
))!;
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 0, rlnInstance);
});
it("toProtoObj", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const rlnEncoder = new RLNEncoder(
createEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createDecoder(TEST_CONSTANTS.contentTopic, TEST_CONSTANTS.routingInfo)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
proto!
)) as RlnMessage<IDecodedMessage>;
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 0, rlnInstance);
});
});
describe("RLN codec with version 1", () => {
it("Symmetric, toWire", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const symKey = generateSymmetricKey();
const rlnEncoder = new RLNEncoder(
createSymEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo,
symKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createSymDecoder(
TEST_CONSTANTS.contentTopic,
TEST_CONSTANTS.routingInfo,
symKey
)
);
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
protoResult!
))!;
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 1, rlnInstance);
});
it("Symmetric, toProtoObj", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const symKey = generateSymmetricKey();
const rlnEncoder = new RLNEncoder(
createSymEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo,
symKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createSymDecoder(
TEST_CONSTANTS.contentTopic,
TEST_CONSTANTS.routingInfo,
symKey
)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
proto!
);
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 1, rlnInstance);
});
it("Asymmetric, toWire", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const privateKey = generatePrivateKey();
const publicKey = getPublicKey(privateKey);
const rlnEncoder = new RLNEncoder(
createAsymEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo,
publicKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createAsymDecoder(
TEST_CONSTANTS.contentTopic,
TEST_CONSTANTS.routingInfo,
privateKey
)
);
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
protoResult!
))!;
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 1, rlnInstance);
});
it("Asymmetric, toProtoObj", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const privateKey = generatePrivateKey();
const publicKey = getPublicKey(privateKey);
const rlnEncoder = new RLNEncoder(
createAsymEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo,
publicKey
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createAsymDecoder(
TEST_CONSTANTS.contentTopic,
TEST_CONSTANTS.routingInfo,
privateKey
)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
proto!
);
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 1, rlnInstance);
});
});
describe("RLN Codec - epoch", () => {
it("toProtoObj", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const rlnEncoder = new RLNEncoder(
createEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createDecoder(TEST_CONSTANTS.contentTopic, TEST_CONSTANTS.routingInfo)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
proto!
)) as RlnMessage<IDecodedMessage>;
const epochBytes = proto!.rateLimitProof!.epoch;
const epoch = epochBytesToInt(epochBytes);
expect(msg.epoch!.toString(10).length).to.eq(9);
expect(msg.epoch).to.eq(epoch);
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 0, rlnInstance);
});
});
describe("RLN codec with version 0 and meta setter", () => {
it("toWire", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const metaSetter = createTestMetaSetter();
const rlnEncoder = createRLNEncoder({
encoder: createEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo,
metaSetter
}),
rlnInstance,
index,
credential
});
const rlnDecoder = createRLNDecoder({
rlnInstance,
decoder: createDecoder(
TEST_CONSTANTS.contentTopic,
TEST_CONSTANTS.routingInfo
)
});
const bytes = await rlnEncoder.toWire({ payload });
expect(bytes).to.not.be.undefined;
const protoResult = await rlnDecoder.fromWireToProtoObj(bytes!);
expect(protoResult).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
protoResult!
))!;
const expectedMeta = metaSetter({
...EMPTY_PROTO_MESSAGE,
payload: protoResult!.payload
});
expect(msg!.meta).to.deep.eq(expectedMeta);
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 0, rlnInstance);
});
it("toProtoObj", async function () {
const { rlnInstance, credential, index, payload } =
await createTestRLNCodecSetup();
const metaSetter = createTestMetaSetter();
const rlnEncoder = new RLNEncoder(
createEncoder({
contentTopic: TEST_CONSTANTS.contentTopic,
routingInfo: TEST_CONSTANTS.routingInfo,
metaSetter
}),
rlnInstance,
index,
credential
);
const rlnDecoder = new RLNDecoder(
rlnInstance,
createDecoder(TEST_CONSTANTS.contentTopic, TEST_CONSTANTS.routingInfo)
);
const proto = await rlnEncoder.toProtoObj({ payload });
expect(proto).to.not.be.undefined;
const msg = (await rlnDecoder.fromProtoObj(
TEST_CONSTANTS.emptyPubsubTopic,
proto!
)) as RlnMessage<IDecodedMessage>;
const expectedMeta = metaSetter({
...EMPTY_PROTO_MESSAGE,
payload: msg!.payload
});
expect(msg!.meta).to.deep.eq(expectedMeta);
verifyRLNMessage(msg, payload, TEST_CONSTANTS.contentTopic, 0, rlnInstance);
});
});

View File

@ -1,88 +0,0 @@
import type { IProtoMessage } from "@waku/interfaces";
import { createRoutingInfo } from "@waku/utils";
import { expect } from "chai";
import { createRLN } from "./create.js";
import type { IdentityCredential } from "./identity.js";
export interface TestRLNCodecSetup {
rlnInstance: any;
credential: IdentityCredential;
index: number;
payload: Uint8Array;
}
export const TEST_CONSTANTS = {
contentTopic: "/test/1/waku-message/utf8",
emptyPubsubTopic: "",
defaultIndex: 0,
defaultPayload: new Uint8Array([1, 2, 3, 4, 5]),
routingInfo: createRoutingInfo(
{
clusterId: 0,
numShardsInCluster: 2
},
{ contentTopic: "/test/1/waku-message/utf8" }
)
} as const;
export const EMPTY_PROTO_MESSAGE = {
timestamp: undefined,
contentTopic: "",
ephemeral: undefined,
meta: undefined,
rateLimitProof: undefined,
version: undefined
} as const;
/**
* Creates a basic RLN setup for codec tests
*/
export async function createTestRLNCodecSetup(): Promise<TestRLNCodecSetup> {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
rlnInstance.zerokit.insertMember(credential.IDCommitment);
return {
rlnInstance,
credential,
index: TEST_CONSTANTS.defaultIndex,
payload: TEST_CONSTANTS.defaultPayload
};
}
/**
* Creates a meta setter function for testing
*/
export function createTestMetaSetter(): (
msg: IProtoMessage & { meta: undefined }
) => Uint8Array {
return (msg: IProtoMessage & { meta: undefined }): Uint8Array => {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint32(0, msg.payload.length, false);
return new Uint8Array(buffer);
};
}
/**
* Verifies common RLN message properties
*/
export function verifyRLNMessage(
msg: any,
payload: Uint8Array,
contentTopic: string,
version: number,
rlnInstance: any
): void {
expect(msg.rateLimitProof).to.not.be.undefined;
expect(msg.verify([rlnInstance.zerokit.getMerkleRoot()])).to.be.true;
expect(msg.verifyNoRoot()).to.be.true;
expect(msg.epoch).to.not.be.undefined;
expect(msg.epoch).to.be.gt(0);
expect(msg.contentTopic).to.eq(contentTopic);
expect(msg.msg.version).to.eq(version);
expect(msg.payload).to.deep.eq(payload);
expect(msg.timestamp).to.not.be.undefined;
}

View File

@ -1,138 +0,0 @@
import type {
IDecodedMessage,
IDecoder,
IEncoder,
IMessage,
IProtoMessage,
IRateLimitProof,
IRoutingInfo
} from "@waku/interfaces";
import { Logger } from "@waku/utils";
import type { IdentityCredential } from "./identity.js";
import { RlnMessage, toRLNSignal } from "./message.js";
import { RLNInstance } from "./rln.js";
const log = new Logger("rln:encoder");
export class RLNEncoder implements IEncoder {
private readonly idSecretHash: Uint8Array;
public constructor(
private readonly encoder: IEncoder,
private readonly rlnInstance: RLNInstance,
private readonly index: number,
identityCredential: IdentityCredential
) {
if (index < 0) throw new Error("Invalid membership index");
this.idSecretHash = identityCredential.IDSecretHash;
}
public async toWire(message: IMessage): Promise<Uint8Array | undefined> {
message.rateLimitProof = await this.generateProof(message);
log.info("Proof generated", message.rateLimitProof);
return this.encoder.toWire(message);
}
public async toProtoObj(
message: IMessage
): Promise<IProtoMessage | undefined> {
const protoMessage = await this.encoder.toProtoObj(message);
if (!protoMessage) return;
protoMessage.contentTopic = this.contentTopic;
protoMessage.rateLimitProof = await this.generateProof(message);
log.info("Proof generated", protoMessage.rateLimitProof);
return protoMessage;
}
private async generateProof(message: IMessage): Promise<IRateLimitProof> {
const signal = toRLNSignal(this.contentTopic, message);
return this.rlnInstance.zerokit.generateRLNProof(
signal,
this.index,
message.timestamp,
this.idSecretHash
);
}
public get pubsubTopic(): string {
return this.encoder.pubsubTopic;
}
public get routingInfo(): IRoutingInfo {
return this.encoder.routingInfo;
}
public get contentTopic(): string {
return this.encoder.contentTopic;
}
public get ephemeral(): boolean {
return this.encoder.ephemeral;
}
}
type RLNEncoderOptions = {
encoder: IEncoder;
rlnInstance: RLNInstance;
index: number;
credential: IdentityCredential;
};
export const createRLNEncoder = (options: RLNEncoderOptions): RLNEncoder => {
return new RLNEncoder(
options.encoder,
options.rlnInstance,
options.index,
options.credential
);
};
export class RLNDecoder<T extends IDecodedMessage>
implements IDecoder<RlnMessage<T>>
{
public constructor(
private readonly rlnInstance: RLNInstance,
private readonly decoder: IDecoder<T>
) {}
public get pubsubTopic(): string {
return this.decoder.pubsubTopic;
}
public get contentTopic(): string {
return this.decoder.contentTopic;
}
public fromWireToProtoObj(
bytes: Uint8Array
): Promise<IProtoMessage | undefined> {
const protoMessage = this.decoder.fromWireToProtoObj(bytes);
log.info("Message decoded", protoMessage);
return Promise.resolve(protoMessage);
}
public async fromProtoObj(
pubsubTopic: string,
proto: IProtoMessage
): Promise<RlnMessage<T> | undefined> {
const msg: T | undefined = await this.decoder.fromProtoObj(
pubsubTopic,
proto
);
if (!msg) return;
return new RlnMessage(this.rlnInstance, msg, proto.rateLimitProof);
}
}
type RLNDecoderOptions<T extends IDecodedMessage> = {
decoder: IDecoder<T>;
rlnInstance: RLNInstance;
};
export const createRLNDecoder = <T extends IDecodedMessage>(
options: RLNDecoderOptions<T>
): RLNDecoder<T> => {
return new RLNDecoder(options.rlnInstance, options.decoder);
};

View File

@ -19,26 +19,16 @@ export const PRICE_CALCULATOR_CONTRACT = {
* @see https://github.com/waku-org/specs/blob/master/standards/core/rln-contract.md#implementation-suggestions
*/
export const RATE_LIMIT_TIERS = {
LOW: 20, // Suggested minimum rate - 20 messages per epoch
MEDIUM: 200,
HIGH: 600 // Suggested maximum rate - 600 messages per epoch
STANDARD: 300,
MAX: 600
} as const;
// Global rate limit parameters
export const RATE_LIMIT_PARAMS = {
MIN_RATE: RATE_LIMIT_TIERS.LOW,
MAX_RATE: RATE_LIMIT_TIERS.HIGH,
MAX_TOTAL_RATE: 160_000, // Maximum total rate limit across all memberships
EPOCH_LENGTH: 600 // Epoch length in seconds (10 minutes)
MIN_RATE: RATE_LIMIT_TIERS.STANDARD,
MAX_RATE: RATE_LIMIT_TIERS.MAX,
MAX_TOTAL_RATE: 160_000,
EPOCH_LENGTH: 600
} as const;
/**
* Default Q value for the RLN contract
* This is the upper bound for the ID commitment
* @see https://github.com/waku-org/specs/blob/master/standards/core/rln-contract.md#implementation-suggestions
*/
export const RLN_Q = BigInt(
"21888242871839275222246405745257275088548364400416034343698204186575808495617"
);
export const DEFAULT_RATE_LIMIT = RATE_LIMIT_PARAMS.MAX_RATE;

View File

@ -1,3 +1,2 @@
export { RLNContract } from "./rln_contract.js";
export * from "./constants.js";
export * from "./types.js";

View File

@ -3,7 +3,6 @@ import { ethers } from "ethers";
import { IdentityCredential } from "../identity.js";
import { DecryptedCredentials } from "../keystore/types.js";
import { BytesUtils } from "../utils/bytes.js";
import { RLN_ABI } from "./abi/rln.js";
import {
@ -632,7 +631,7 @@ export class RLNBaseContract {
permit.v,
permit.r,
permit.s,
BytesUtils.buildBigIntFromUint8ArrayBE(identity.IDCommitment),
identity.IDCommitmentBigInt,
this.rateLimit,
idCommitmentsToErase.map((id) => ethers.BigNumber.from(id))
);

View File

@ -1,90 +0,0 @@
import { hexToBytes } from "@waku/utils/bytes";
import { expect, use } from "chai";
import chaiAsPromised from "chai-as-promised";
import * as ethers from "ethers";
import sinon, { SinonSandbox } from "sinon";
import { createTestRLNInstance, initializeRLNContract } from "./test_setup.js";
import {
createMockRegistryContract,
createRegisterStub,
mockRLNRegisteredEvent,
verifyRegistration
} from "./test_utils.js";
use(chaiAsPromised);
describe("RLN Contract abstraction - RLN", () => {
let sandbox: SinonSandbox;
beforeEach(async () => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
describe("Member Registration", () => {
it("should fetch members from events and store them in the RLN instance", async () => {
const { rlnInstance, insertMemberSpy } = await createTestRLNInstance();
const membershipRegisteredEvent = mockRLNRegisteredEvent();
const queryFilterStub = sinon.stub().returns([membershipRegisteredEvent]);
const mockedRegistryContract = createMockRegistryContract({
queryFilter: queryFilterStub
});
const rlnContract = await initializeRLNContract(
rlnInstance,
mockedRegistryContract
);
await rlnContract.fetchMembers({
fromBlock: 0,
fetchRange: 1000,
fetchChunks: 2
});
expect(
insertMemberSpy.calledWith(
ethers.utils.zeroPad(
hexToBytes(membershipRegisteredEvent.args!.idCommitment),
32
)
)
).to.be.true;
expect(queryFilterStub.called).to.be.true;
});
it("should register a member", async () => {
const { rlnInstance, identity, insertMemberSpy } =
await createTestRLNInstance();
const registerStub = createRegisterStub(identity);
const mockedRegistryContract = createMockRegistryContract({
register: registerStub,
queryFilter: () => []
});
const rlnContract = await initializeRLNContract(
rlnInstance,
mockedRegistryContract
);
const decryptedCredentials =
await rlnContract.registerWithIdentity(identity);
if (!decryptedCredentials) {
throw new Error("Failed to retrieve credentials");
}
verifyRegistration(
decryptedCredentials,
identity,
registerStub,
insertMemberSpy
);
});
});
});

View File

@ -1,147 +0,0 @@
import { Logger } from "@waku/utils";
import { hexToBytes } from "@waku/utils/bytes";
import { ethers } from "ethers";
import type { RLNInstance } from "../rln.js";
import { MerkleRootTracker } from "../root_tracker.js";
import { BytesUtils } from "../utils/bytes.js";
import { RLNBaseContract } from "./rln_base_contract.js";
import { RLNContractInitOptions } from "./types.js";
const log = new Logger("rln:contract");
export class RLNContract extends RLNBaseContract {
private instance: RLNInstance;
private merkleRootTracker: MerkleRootTracker;
/**
* Asynchronous initializer for RLNContract.
* Allows injecting a mocked contract for testing purposes.
*/
public static async init(
rlnInstance: RLNInstance,
options: RLNContractInitOptions
): Promise<RLNContract> {
const rlnContract = new RLNContract(rlnInstance, options);
return rlnContract;
}
private constructor(
rlnInstance: RLNInstance,
options: RLNContractInitOptions
) {
super(options);
this.instance = rlnInstance;
const initialRoot = rlnInstance.zerokit.getMerkleRoot();
this.merkleRootTracker = new MerkleRootTracker(5, initialRoot);
}
public override processEvents(events: ethers.Event[]): void {
const toRemoveTable = new Map<number, number[]>();
const toInsertTable = new Map<number, ethers.Event[]>();
events.forEach((evt) => {
if (!evt.args) {
return;
}
if (
evt.event === "MembershipErased" ||
evt.event === "MembershipExpired"
) {
let index = evt.args.index;
if (!index) {
return;
}
if (typeof index === "number" || typeof index === "string") {
index = ethers.BigNumber.from(index);
} else {
log.error("Index is not a number or string", {
index,
event: evt
});
return;
}
const toRemoveVal = toRemoveTable.get(evt.blockNumber);
if (toRemoveVal != undefined) {
toRemoveVal.push(index.toNumber());
toRemoveTable.set(evt.blockNumber, toRemoveVal);
} else {
toRemoveTable.set(evt.blockNumber, [index.toNumber()]);
}
} else if (evt.event === "MembershipRegistered") {
let eventsPerBlock = toInsertTable.get(evt.blockNumber);
if (eventsPerBlock == undefined) {
eventsPerBlock = [];
}
eventsPerBlock.push(evt);
toInsertTable.set(evt.blockNumber, eventsPerBlock);
}
});
this.removeMembers(this.instance, toRemoveTable);
this.insertMembers(this.instance, toInsertTable);
}
private insertMembers(
rlnInstance: RLNInstance,
toInsert: Map<number, ethers.Event[]>
): void {
toInsert.forEach((events: ethers.Event[], blockNumber: number) => {
events.forEach((evt) => {
if (!evt.args) return;
const _idCommitment = evt.args.idCommitment as string;
let index = evt.args.index;
if (!_idCommitment || !index) {
return;
}
if (typeof index === "number" || typeof index === "string") {
index = ethers.BigNumber.from(index);
}
const idCommitment = BytesUtils.zeroPadLE(
hexToBytes(_idCommitment),
32
);
rlnInstance.zerokit.insertMember(idCommitment);
const numericIndex = index.toNumber();
this._members.set(numericIndex, {
index,
idCommitment: _idCommitment
});
});
const currentRoot = rlnInstance.zerokit.getMerkleRoot();
this.merkleRootTracker.pushRoot(blockNumber, currentRoot);
});
}
private removeMembers(
rlnInstance: RLNInstance,
toRemove: Map<number, number[]>
): void {
const removeDescending = new Map([...toRemove].reverse());
removeDescending.forEach((indexes: number[], blockNumber: number) => {
indexes.forEach((index) => {
if (this._members.has(index)) {
this._members.delete(index);
rlnInstance.zerokit.deleteMember(index);
}
});
this.merkleRootTracker.backFill(blockNumber);
});
}
}

View File

@ -1,86 +0,0 @@
import { hexToBytes } from "@waku/utils/bytes";
import { ethers } from "ethers";
import sinon from "sinon";
import { createRLN } from "../create.js";
import type { IdentityCredential } from "../identity.js";
import { DEFAULT_RATE_LIMIT, RLN_CONTRACT } from "./constants.js";
import { RLNContract } from "./rln_contract.js";
export interface TestRLNInstance {
rlnInstance: any;
identity: IdentityCredential;
insertMemberSpy: sinon.SinonStub;
}
/**
* Creates a test RLN instance with basic setup
*/
export async function createTestRLNInstance(): Promise<TestRLNInstance> {
const rlnInstance = await createRLN();
const insertMemberSpy = sinon.stub();
rlnInstance.zerokit.insertMember = insertMemberSpy;
const mockSignature =
"0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c";
const identity =
rlnInstance.zerokit.generateSeededIdentityCredential(mockSignature);
return {
rlnInstance,
identity,
insertMemberSpy
};
}
/**
* Initializes an RLN contract with the given registry contract
*/
export async function initializeRLNContract(
rlnInstance: any,
mockedRegistryContract: ethers.Contract
): Promise<RLNContract> {
const provider = new ethers.providers.JsonRpcProvider();
const voidSigner = new ethers.VoidSigner(RLN_CONTRACT.address, provider);
const originalRegister = mockedRegistryContract.register;
(mockedRegistryContract as any).register = function (...args: any[]) {
const result = originalRegister.apply(this, args);
if (args[0] && rlnInstance.zerokit) {
const idCommitmentBigInt = args[0];
const idCommitmentHex =
"0x" + idCommitmentBigInt.toString(16).padStart(64, "0");
const idCommitment = ethers.utils.zeroPad(
hexToBytes(idCommitmentHex),
32
);
rlnInstance.zerokit.insertMember(idCommitment);
}
return result;
};
const contract = await RLNContract.init(rlnInstance, {
address: RLN_CONTRACT.address,
signer: voidSigner,
rateLimit: DEFAULT_RATE_LIMIT,
contract: mockedRegistryContract
});
return contract;
}
/**
* Common test message data
*/
export const TEST_DATA = {
contentTopic: "/test/1/waku-message/utf8",
emptyPubsubTopic: "",
testMessage: Uint8Array.from(
"Hello World".split("").map((x) => x.charCodeAt(0))
),
mockSignature:
"0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c"
};

View File

@ -1,179 +0,0 @@
import { hexToBytes } from "@waku/utils/bytes";
import { expect } from "chai";
import * as ethers from "ethers";
import sinon from "sinon";
import type { IdentityCredential } from "../identity.js";
import { DEFAULT_RATE_LIMIT, RLN_CONTRACT } from "./constants.js";
export const mockRateLimits = {
minRate: 20,
maxRate: 600,
maxTotalRate: 1200,
currentTotalRate: 500
};
type MockProvider = {
getLogs: () => never[];
getBlockNumber: () => Promise<number>;
getNetwork: () => Promise<{ chainId: number }>;
};
type MockFilters = {
MembershipRegistered: () => { address: string };
MembershipErased: () => { address: string };
MembershipExpired: () => { address: string };
};
export function createMockProvider(): MockProvider {
return {
getLogs: () => [],
getBlockNumber: () => Promise.resolve(1000),
getNetwork: () => Promise.resolve({ chainId: 11155111 })
};
}
export function createMockFilters(): MockFilters {
return {
MembershipRegistered: () => ({ address: RLN_CONTRACT.address }),
MembershipErased: () => ({ address: RLN_CONTRACT.address }),
MembershipExpired: () => ({ address: RLN_CONTRACT.address })
};
}
type ContractOverrides = Partial<{
filters: Record<string, unknown>;
[key: string]: unknown;
}>;
export function createMockRegistryContract(
overrides: ContractOverrides = {}
): ethers.Contract {
const filters = {
MembershipRegistered: () => ({ address: RLN_CONTRACT.address }),
MembershipErased: () => ({ address: RLN_CONTRACT.address }),
MembershipExpired: () => ({ address: RLN_CONTRACT.address })
};
const baseContract = {
minMembershipRateLimit: () =>
Promise.resolve(ethers.BigNumber.from(mockRateLimits.minRate)),
maxMembershipRateLimit: () =>
Promise.resolve(ethers.BigNumber.from(mockRateLimits.maxRate)),
maxTotalRateLimit: () =>
Promise.resolve(ethers.BigNumber.from(mockRateLimits.maxTotalRate)),
currentTotalRateLimit: () =>
Promise.resolve(ethers.BigNumber.from(mockRateLimits.currentTotalRate)),
queryFilter: () => [],
provider: createMockProvider(),
filters,
on: () => ({}),
removeAllListeners: () => ({}),
register: () => ({
wait: () =>
Promise.resolve({
events: [mockRLNRegisteredEvent()]
})
}),
estimateGas: {
register: () => Promise.resolve(ethers.BigNumber.from(100000))
},
functions: {
register: () => Promise.resolve()
},
getMemberIndex: () => Promise.resolve(null),
interface: {
getEvent: (eventName: string) => ({
name: eventName,
format: () => {}
})
},
address: RLN_CONTRACT.address
};
// Merge overrides while preserving filters
const merged = {
...baseContract,
...overrides,
filters: { ...filters, ...(overrides.filters || {}) }
};
return merged as unknown as ethers.Contract;
}
export function mockRLNRegisteredEvent(idCommitment?: string): ethers.Event {
return {
args: {
idCommitment:
idCommitment ||
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
membershipRateLimit: ethers.BigNumber.from(DEFAULT_RATE_LIMIT),
index: ethers.BigNumber.from(1)
},
event: "MembershipRegistered"
} as unknown as ethers.Event;
}
export function formatIdCommitment(idCommitmentBigInt: bigint): string {
return "0x" + idCommitmentBigInt.toString(16).padStart(64, "0");
}
export function createRegisterStub(
identity: IdentityCredential
): sinon.SinonStub {
return sinon.stub().callsFake(() => ({
wait: () =>
Promise.resolve({
events: [
{
event: "MembershipRegistered",
args: {
idCommitment: formatIdCommitment(identity.IDCommitmentBigInt),
membershipRateLimit: ethers.BigNumber.from(DEFAULT_RATE_LIMIT),
index: ethers.BigNumber.from(1)
}
}
]
})
}));
}
export function verifyRegistration(
decryptedCredentials: any,
identity: IdentityCredential,
registerStub: sinon.SinonStub,
insertMemberSpy: sinon.SinonStub
): void {
if (!decryptedCredentials) {
throw new Error("Decrypted credentials should not be undefined");
}
// Verify registration call
expect(
registerStub.calledWith(
sinon.match.same(identity.IDCommitmentBigInt),
sinon.match.same(DEFAULT_RATE_LIMIT),
sinon.match.array,
sinon.match.object
)
).to.be.true;
// Verify credential properties
expect(decryptedCredentials).to.have.property("identity");
expect(decryptedCredentials).to.have.property("membership");
expect(decryptedCredentials.membership).to.include({
address: RLN_CONTRACT.address,
treeIndex: 1
});
// Verify member insertion
const expectedIdCommitment = ethers.utils.zeroPad(
hexToBytes(formatIdCommitment(identity.IDCommitmentBigInt)),
32
);
expect(insertMemberSpy.callCount).to.equal(1);
expect(insertMemberSpy.getCall(0).args[0]).to.deep.equal(
expectedIdCommitment
);
}

View File

@ -1,137 +0,0 @@
import { assert, expect } from "chai";
import { createRLN } from "./create.js";
describe("js-rln", () => {
it("should verify a proof", async function () {
const rlnInstance = await createRLN();
const credential = rlnInstance.zerokit.generateIdentityCredentials();
//peer's index in the Merkle Tree
const index = 5;
// Create a Merkle tree with random members
for (let i = 0; i < 10; i++) {
if (i == index) {
// insert the current peer's pk
rlnInstance.zerokit.insertMember(credential.IDCommitment);
} else {
// create a new key pair
rlnInstance.zerokit.insertMember(
rlnInstance.zerokit.generateIdentityCredentials().IDCommitment
);
}
}
// prepare the message
const uint8Msg = Uint8Array.from(
"Hello World".split("").map((x) => x.charCodeAt(0))
);
// setting up the epoch
const epoch = new Date();
// generating proof
const proof = await rlnInstance.zerokit.generateRLNProof(
uint8Msg,
index,
epoch,
credential.IDSecretHash
);
try {
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.true;
} catch (err) {
assert.fail(0, 1, "should not have failed proof verification");
}
try {
// Modifying the signal so it's invalid
uint8Msg[4] = 4;
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.false;
} catch (err) {
console.log(err);
}
});
it("should verify a proof with a seeded membership key generation", async function () {
const rlnInstance = await createRLN();
const seed = "This is a test seed";
const credential =
rlnInstance.zerokit.generateSeededIdentityCredential(seed);
//peer's index in the Merkle Tree
const index = 5;
// Create a Merkle tree with random members
for (let i = 0; i < 10; i++) {
if (i == index) {
// insert the current peer's pk
rlnInstance.zerokit.insertMember(credential.IDCommitment);
} else {
// create a new key pair
rlnInstance.zerokit.insertMember(
rlnInstance.zerokit.generateIdentityCredentials().IDCommitment
);
}
}
// prepare the message
const uint8Msg = Uint8Array.from(
"Hello World".split("").map((x) => x.charCodeAt(0))
);
// setting up the epoch
const epoch = new Date();
// generating proof
const proof = await rlnInstance.zerokit.generateRLNProof(
uint8Msg,
index,
epoch,
credential.IDSecretHash
);
try {
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.true;
} catch (err) {
assert.fail(0, 1, "should not have failed proof verification");
}
try {
// Modifying the signal so it's invalid
uint8Msg[4] = 4;
// verify the proof
const verifResult = rlnInstance.zerokit.verifyRLNProof(proof, uint8Msg);
expect(verifResult).to.be.false;
} catch (err) {
console.log(err);
}
});
it("should generate the same membership key if the same seed is provided", async function () {
const rlnInstance = await createRLN();
const seed = "This is a test seed";
const memKeys1 = rlnInstance.zerokit.generateSeededIdentityCredential(seed);
const memKeys2 = rlnInstance.zerokit.generateSeededIdentityCredential(seed);
memKeys1.IDCommitment.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDCommitment[index]);
});
memKeys1.IDNullifier.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDNullifier[index]);
});
memKeys1.IDSecretHash.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDSecretHash[index]);
});
memKeys1.IDTrapdoor.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDTrapdoor[index]);
});
});
});

View File

@ -1,11 +1,8 @@
import { hmac } from "@noble/hashes/hmac";
import { sha256 } from "@noble/hashes/sha2";
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { RLN_CONTRACT, RLN_Q } from "./contract/constants.js";
import { RLN_CONTRACT } from "./contract/constants.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import type {
DecryptedCredentials,
@ -13,7 +10,6 @@ import type {
} from "./keystore/index.js";
import { KeystoreEntity, Password } from "./keystore/types.js";
import { RegisterMembershipOptions, StartRLNOptions } from "./types.js";
import { BytesUtils } from "./utils/bytes.js";
import { extractMetaMaskSigner } from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
@ -21,7 +17,6 @@ const log = new Logger("rln:credentials");
/**
* Manages credentials for RLN
* This is a lightweight implementation of the RLN contract that doesn't require Zerokit
* It is used to register membership and generate identity credentials
*/
export class RLNCredentialsManager {
@ -34,9 +29,9 @@ export class RLNCredentialsManager {
protected keystore = Keystore.create();
public credentials: undefined | DecryptedCredentials;
public zerokit: undefined | Zerokit;
public zerokit: Zerokit;
public constructor(zerokit?: Zerokit) {
public constructor(zerokit: Zerokit) {
log.info("RLNCredentialsManager initialized");
this.zerokit = zerokit;
}
@ -81,7 +76,7 @@ export class RLNCredentialsManager {
this.contract = await RLNBaseContract.create({
address: address!,
signer: signer!,
rateLimit: rateLimit ?? this.zerokit?.rateLimit
rateLimit: rateLimit ?? this.zerokit.rateLimit
});
log.info("RLNCredentialsManager successfully started");
@ -106,18 +101,10 @@ export class RLNCredentialsManager {
let identity = "identity" in options && options.identity;
if ("signature" in options) {
log.info("Generating identity from signature");
if (this.zerokit) {
log.info("Using Zerokit to generate identity");
identity = this.zerokit.generateSeededIdentityCredential(
options.signature
);
} else {
log.info("Using local implementation to generate identity");
identity = await this.generateSeededIdentityCredential(
options.signature
);
}
log.info("Using Zerokit to generate identity");
identity = this.zerokit.generateSeededIdentityCredential(
options.signature
);
}
if (!identity) {
@ -242,55 +229,4 @@ export class RLNCredentialsManager {
);
}
}
/**
* Generates an identity credential from a seed string
* This is a pure implementation that doesn't rely on Zerokit
* @param seed A string seed to generate the identity from
* @returns IdentityCredential
*/
private async generateSeededIdentityCredential(
seed: string
): Promise<IdentityCredential> {
log.info("Generating seeded identity credential");
// Convert the seed to bytes
const encoder = new TextEncoder();
const seedBytes = encoder.encode(seed);
// Generate deterministic values using HMAC-SHA256
// We use different context strings for each component to ensure they're different
const idTrapdoorBE = hmac(sha256, seedBytes, encoder.encode("IDTrapdoor"));
const idNullifierBE = hmac(
sha256,
seedBytes,
encoder.encode("IDNullifier")
);
const combinedBytes = new Uint8Array([...idTrapdoorBE, ...idNullifierBE]);
const idSecretHashBE = sha256(combinedBytes);
const idCommitmentRawBE = sha256(idSecretHashBE);
const idCommitmentBE = this.reduceIdCommitment(idCommitmentRawBE);
log.info(
"Successfully generated identity credential, storing in Big Endian format"
);
return new IdentityCredential(
idTrapdoorBE,
idNullifierBE,
idSecretHashBE,
idCommitmentBE
);
}
/**
* Helper: take 32-byte BE, reduce mod Q, return 32-byte BE
*/
private reduceIdCommitment(
bytesBE: Uint8Array,
limit: bigint = RLN_Q
): Uint8Array {
const nBE = BytesUtils.buildBigIntFromUint8ArrayBE(bytesBE);
return BytesUtils.bigIntToUint8Array32BE(nBE % limit);
}
}

View File

@ -11,8 +11,7 @@ export class IdentityCredential {
public readonly IDSecretHash: Uint8Array,
public readonly IDCommitment: Uint8Array
) {
this.IDCommitmentBigInt =
BytesUtils.buildBigIntFromUint8ArrayBE(IDCommitment);
this.IDCommitmentBigInt = BytesUtils.toBigInt(IDCommitment);
}
public static fromBytes(memKeys: Uint8Array): IdentityCredential {

View File

@ -1,28 +1,18 @@
import { RLNDecoder, RLNEncoder } from "./codec.js";
import { RLN_ABI } from "./contract/abi/rln.js";
import { RLN_CONTRACT, RLNContract } from "./contract/index.js";
import { RLN_CONTRACT } from "./contract/index.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
import { createRLN } from "./create.js";
import { RLNCredentialsManager } from "./credentials_manager.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import { Proof } from "./proof.js";
import { RLNInstance } from "./rln.js";
import { MerkleRootTracker } from "./root_tracker.js";
import { extractMetaMaskSigner } from "./utils/index.js";
export {
RLNCredentialsManager,
RLNBaseContract,
createRLN,
Keystore,
RLNInstance,
IdentityCredential,
Proof,
RLNEncoder,
RLNDecoder,
MerkleRootTracker,
RLNContract,
RLN_CONTRACT,
extractMetaMaskSigner,
RLN_ABI

View File

@ -222,9 +222,7 @@ describe("Keystore", () => {
])
} as unknown as IdentityCredential;
// Add the missing property for test correctness
identity.IDCommitmentBigInt = BytesUtils.buildBigIntFromUint8ArrayBE(
identity.IDCommitment
);
identity.IDCommitmentBigInt = BytesUtils.toBigInt(identity.IDCommitment);
const membership = {
chainId: "0xAA36A7",
treeIndex: 8,
@ -276,9 +274,7 @@ describe("Keystore", () => {
58, 94, 20, 246, 8, 33, 65, 238, 37, 112, 97, 65, 241, 255, 93, 171, 15
]
} as unknown as IdentityCredential;
identity.IDCommitmentBigInt = BytesUtils.buildBigIntFromUint8ArrayBE(
identity.IDCommitment
);
identity.IDCommitmentBigInt = BytesUtils.toBigInt(identity.IDCommitment);
const membership = {
chainId: "0xAA36A7",
treeIndex: 8,

View File

@ -264,20 +264,14 @@ export class Keystore {
_.get(obj, "identityCredential.idSecretHash", [])
);
// Big Endian
const idCommitmentBE = BytesUtils.switchEndianness(idCommitmentLE);
const idTrapdoorBE = BytesUtils.switchEndianness(idTrapdoorLE);
const idNullifierBE = BytesUtils.switchEndianness(idNullifierLE);
const idSecretHashBE = BytesUtils.switchEndianness(idSecretHashLE);
const idCommitmentBigInt =
BytesUtils.buildBigIntFromUint8ArrayBE(idCommitmentBE);
const idCommitmentBigInt = BytesUtils.toBigInt(idCommitmentLE);
return {
identity: {
IDCommitment: idCommitmentBE,
IDTrapdoor: idTrapdoorBE,
IDNullifier: idNullifierBE,
IDSecretHash: idSecretHashBE,
IDCommitment: idCommitmentLE,
IDTrapdoor: idTrapdoorLE,
IDNullifier: idNullifierLE,
IDSecretHash: idSecretHashLE,
IDCommitmentBigInt: idCommitmentBigInt
},
membership: {
@ -329,35 +323,18 @@ export class Keystore {
// follows nwaku implementation
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L98
// IdentityCredential is stored in Big Endian format => switch to Little Endian
private static fromIdentityToBytes(options: KeystoreEntity): Uint8Array {
const { IDCommitment, IDNullifier, IDSecretHash, IDTrapdoor } =
options.identity;
const idCommitmentLE = BytesUtils.switchEndianness(IDCommitment);
const idNullifierLE = BytesUtils.switchEndianness(IDNullifier);
const idSecretHashLE = BytesUtils.switchEndianness(IDSecretHash);
const idTrapdoorLE = BytesUtils.switchEndianness(IDTrapdoor);
// eslint-disable-next-line no-console
console.log({
idCommitmentBE: IDCommitment,
idCommitmentLE,
idNullifierBE: IDNullifier,
idNullifierLE,
idSecretHashBE: IDSecretHash,
idSecretHashLE,
idTrapdoorBE: IDTrapdoor,
idTrapdoorLE
});
return utf8ToBytes(
JSON.stringify({
treeIndex: options.membership.treeIndex,
identityCredential: {
idCommitment: Array.from(idCommitmentLE),
idNullifier: Array.from(idNullifierLE),
idSecretHash: Array.from(idSecretHashLE),
idTrapdoor: Array.from(idTrapdoorLE)
idCommitment: Array.from(IDCommitment),
idNullifier: Array.from(IDNullifier),
idSecretHash: Array.from(IDSecretHash),
idTrapdoor: Array.from(IDTrapdoor)
},
membershipContract: {
chainId: options.membership.chainId,

View File

@ -1,81 +0,0 @@
import { message } from "@waku/core";
import type {
IDecodedMessage,
IMessage,
IRateLimitProof,
IRlnMessage
} from "@waku/interfaces";
import * as utils from "@waku/utils/bytes";
import { RLNInstance } from "./rln.js";
import { epochBytesToInt } from "./utils/index.js";
export function toRLNSignal(contentTopic: string, msg: IMessage): Uint8Array {
const contentTopicBytes = utils.utf8ToBytes(contentTopic ?? "");
return new Uint8Array([...(msg.payload ?? []), ...contentTopicBytes]);
}
export class RlnMessage<T extends IDecodedMessage> implements IRlnMessage {
public pubsubTopic = "";
public version = message.version_0.Version;
public constructor(
private rlnInstance: RLNInstance,
private msg: T,
public rateLimitProof: IRateLimitProof | undefined
) {}
public verify(roots: Uint8Array[]): boolean | undefined {
return this.rateLimitProof
? this.rlnInstance.zerokit.verifyWithRoots(
this.rateLimitProof,
toRLNSignal(this.msg.contentTopic, this.msg),
roots
) // this.rlnInstance.verifyRLNProof once issue status-im/nwaku#1248 is fixed
: undefined;
}
public verifyNoRoot(): boolean | undefined {
return this.rateLimitProof
? this.rlnInstance.zerokit.verifyWithNoRoot(
this.rateLimitProof,
toRLNSignal(this.msg.contentTopic, this.msg)
) // this.rlnInstance.verifyRLNProof once issue status-im/nwaku#1248 is fixed
: undefined;
}
public get payload(): Uint8Array {
return this.msg.payload;
}
public get hash(): Uint8Array {
return this.msg.hash;
}
public get hashStr(): string {
return this.msg.hashStr;
}
public get contentTopic(): string {
return this.msg.contentTopic;
}
public get timestamp(): Date | undefined {
return this.msg.timestamp;
}
public get ephemeral(): boolean | undefined {
return this.msg.ephemeral;
}
public get meta(): Uint8Array | undefined {
return this.msg.meta;
}
public get epoch(): number | undefined {
const bytes = this.rateLimitProof?.epoch;
if (!bytes) return undefined;
return epochBytesToInt(bytes);
}
}

View File

@ -1,69 +0,0 @@
import type { IRateLimitProof } from "@waku/interfaces";
import { BytesUtils, poseidonHash } from "./utils/index.js";
const proofOffset = 128;
const rootOffset = proofOffset + 32;
const epochOffset = rootOffset + 32;
const shareXOffset = epochOffset + 32;
const shareYOffset = shareXOffset + 32;
const nullifierOffset = shareYOffset + 32;
const rlnIdentifierOffset = nullifierOffset + 32;
class ProofMetadata {
public constructor(
public readonly nullifier: Uint8Array,
public readonly shareX: Uint8Array,
public readonly shareY: Uint8Array,
public readonly externalNullifier: Uint8Array
) {}
}
export class Proof implements IRateLimitProof {
public readonly proof: Uint8Array;
public readonly merkleRoot: Uint8Array;
public readonly epoch: Uint8Array;
public readonly shareX: Uint8Array;
public readonly shareY: Uint8Array;
public readonly nullifier: Uint8Array;
public readonly rlnIdentifier: Uint8Array;
public constructor(proofBytes: Uint8Array) {
if (proofBytes.length < rlnIdentifierOffset) {
throw new Error("invalid proof");
}
// parse the proof as proof<128> | share_y<32> | nullifier<32> | root<32> | epoch<32> | share_x<32> | rln_identifier<32>
this.proof = proofBytes.subarray(0, proofOffset);
this.merkleRoot = proofBytes.subarray(proofOffset, rootOffset);
this.epoch = proofBytes.subarray(rootOffset, epochOffset);
this.shareX = proofBytes.subarray(epochOffset, shareXOffset);
this.shareY = proofBytes.subarray(shareXOffset, shareYOffset);
this.nullifier = proofBytes.subarray(shareYOffset, nullifierOffset);
this.rlnIdentifier = proofBytes.subarray(
nullifierOffset,
rlnIdentifierOffset
);
}
public extractMetadata(): ProofMetadata {
const externalNullifier = poseidonHash(this.epoch, this.rlnIdentifier);
return new ProofMetadata(
this.nullifier,
this.shareX,
this.shareY,
externalNullifier
);
}
}
export function proofToBytes(p: IRateLimitProof): Uint8Array {
return BytesUtils.concatenate(
p.proof,
p.merkleRoot,
p.epoch,
p.shareX,
p.shareY,
p.nullifier,
p.rlnIdentifier
);
}

Binary file not shown.

View File

@ -1,13 +0,0 @@
declare const verificationKey: {
protocol: string;
curve: string;
nPublic: number;
vk_alpha_1: string[];
vk_beta_2: string[][];
vk_gamma_2: string[][];
vk_delta_2: string[][];
vk_alphabeta_12: string[][][];
IC: string[][];
};
export default verificationKey;

View File

@ -1,112 +0,0 @@
const verificationKey = {
protocol: "groth16",
curve: "bn128",
nPublic: 6,
vk_alpha_1: [
"20124996762962216725442980738609010303800849578410091356605067053491763969391",
"9118593021526896828671519912099489027245924097793322973632351264852174143923",
"1"
],
vk_beta_2: [
[
"4693952934005375501364248788849686435240706020501681709396105298107971354382",
"14346958885444710485362620645446987998958218205939139994511461437152241966681"
],
[
"16851772916911573982706166384196538392731905827088356034885868448550849804972",
"823612331030938060799959717749043047845343400798220427319188951998582076532"
],
["1", "0"]
],
vk_gamma_2: [
[
"10857046999023057135944570762232829481370756359578518086990519993285655852781",
"11559732032986387107991004021392285783925812861821192530917403151452391805634"
],
[
"8495653923123431417604973247489272438418190587263600148770280649306958101930",
"4082367875863433681332203403145435568316851327593401208105741076214120093531"
],
["1", "0"]
],
vk_delta_2: [
[
"8353516066399360694538747105302262515182301251524941126222712285088022964076",
"9329524012539638256356482961742014315122377605267454801030953882967973561832"
],
[
"16805391589556134376869247619848130874761233086443465978238468412168162326401",
"10111259694977636294287802909665108497237922060047080343914303287629927847739"
],
["1", "0"]
],
vk_alphabeta_12: [
[
[
"12608968655665301215455851857466367636344427685631271961542642719683786103711",
"9849575605876329747382930567422916152871921500826003490242628251047652318086"
],
[
"6322029441245076030714726551623552073612922718416871603535535085523083939021",
"8700115492541474338049149013125102281865518624059015445617546140629435818912"
],
[
"10674973475340072635573101639867487770811074181475255667220644196793546640210",
"2926286967251299230490668407790788696102889214647256022788211245826267484824"
]
],
[
[
"9660441540778523475944706619139394922744328902833875392144658911530830074820",
"19548113127774514328631808547691096362144426239827206966690021428110281506546"
],
[
"1870837942477655969123169532603615788122896469891695773961478956740992497097",
"12536105729661705698805725105036536744930776470051238187456307227425796690780"
],
[
"21811903352654147452884857281720047789720483752548991551595462057142824037334",
"19021616763967199151052893283384285352200445499680068407023236283004353578353"
]
]
],
IC: [
[
"11992897507809711711025355300535923222599547639134311050809253678876341466909",
"17181525095924075896332561978747020491074338784673526378866503154966799128110",
"1"
],
[
"17018665030246167677911144513385572506766200776123272044534328594850561667818",
"18601114175490465275436712413925513066546725461375425769709566180981674884464",
"1"
],
[
"18799470100699658367834559797874857804183288553462108031963980039244731716542",
"13064227487174191981628537974951887429496059857753101852163607049188825592007",
"1"
],
[
"17432501889058124609368103715904104425610382063762621017593209214189134571156",
"13406815149699834788256141097399354592751313348962590382887503595131085938635",
"1"
],
[
"10320964835612716439094703312987075811498239445882526576970512041988148264481",
"9024164961646353611176283204118089412001502110138072989569118393359029324867",
"1"
],
[
"718355081067365548229685160476620267257521491773976402837645005858953849298",
"14635482993933988261008156660773180150752190597753512086153001683711587601974",
"1"
],
[
"11777720285956632126519898515392071627539405001940313098390150593689568177535",
"8483603647274280691250972408211651407952870456587066148445913156086740744515",
"1"
]
]
};
export default verificationKey;

View File

@ -1,11 +1,25 @@
export async function builder(
export const builder: (
code: Uint8Array,
sanityCheck: boolean
): Promise<WitnessCalculator>;
sanityCheck?: boolean
) => Promise<WitnessCalculator>;
export class WitnessCalculator {
public calculateWitness(
input: unknown,
sanityCheck: boolean
): Promise<Array<bigint>>;
constructor(instance: any, sanityCheck?: boolean);
circom_version(): number;
calculateWitness(
input: Record<string, unknown>,
sanityCheck?: boolean
): Promise<bigint[]>;
calculateBinWitness(
input: Record<string, unknown>,
sanityCheck?: boolean
): Promise<Uint8Array>;
calculateWTNSBin(
input: Record<string, unknown>,
sanityCheck?: boolean
): Promise<Uint8Array>;
}

View File

@ -1,6 +1,6 @@
// File generated with https://github.com/iden3/circom
// following the instructions from:
// https://github.com/vacp2p/zerokit/tree/master/rln#compiling-circuits
// https://github.com/vacp2p/zerokit/tree/master/rln#advanced-custom-circuit-compilation
export async function builder(code, options) {
options = options || {};

View File

@ -1,37 +1,14 @@
import { createDecoder, createEncoder } from "@waku/core";
import type {
ContentTopic,
IDecodedMessage,
IRoutingInfo,
EncoderOptions as WakuEncoderOptions
} from "@waku/interfaces";
import { Logger } from "@waku/utils";
import init from "@waku/zerokit-rln-wasm";
import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import init, * as zerokitRLN from "@waku/zerokit-rln-wasm";
import {
createRLNDecoder,
createRLNEncoder,
type RLNDecoder,
type RLNEncoder
} from "./codec.js";
import { DEFAULT_RATE_LIMIT } from "./contract/constants.js";
import { RLNCredentialsManager } from "./credentials_manager.js";
import type {
DecryptedCredentials,
EncryptedCredentials
} from "./keystore/index.js";
import verificationKey from "./resources/verification_key";
import * as wc from "./resources/witness_calculator";
import { WitnessCalculator } from "./resources/witness_calculator";
import { Zerokit } from "./zerokit.js";
const log = new Logger("rln");
type WakuRLNEncoderOptions = WakuEncoderOptions & {
credentials: EncryptedCredentials | DecryptedCredentials;
};
export class RLNInstance extends RLNCredentialsManager {
/**
* Create an instance of RLN
@ -39,18 +16,13 @@ export class RLNInstance extends RLNCredentialsManager {
*/
public static async create(): Promise<RLNInstance> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (init as any)?.();
zerokitRLN.init_panic_hook();
await init();
zerokitRLN.initPanicHook();
const witnessCalculator = await RLNInstance.loadWitnessCalculator();
const zkey = await RLNInstance.loadZkey();
const stringEncoder = new TextEncoder();
const vkey = stringEncoder.encode(JSON.stringify(verificationKey));
const DEPTH = 20;
const zkRLN = zerokitRLN.newRLN(DEPTH, zkey, vkey);
const zkRLN = zerokitRLN.newRLN(zkey);
const zerokit = new Zerokit(zkRLN, witnessCalculator, DEFAULT_RATE_LIMIT);
return new RLNInstance(zerokit);
@ -64,39 +36,6 @@ export class RLNInstance extends RLNCredentialsManager {
super(zerokit);
}
public async createEncoder(
options: WakuRLNEncoderOptions
): Promise<RLNEncoder> {
const { credentials: decryptedCredentials } =
await RLNInstance.decryptCredentialsIfNeeded(options.credentials);
const credentials = decryptedCredentials || this.credentials;
if (!credentials) {
throw Error(
"Failed to create Encoder: missing RLN credentials. Use createRLNEncoder directly."
);
}
await this.verifyCredentialsAgainstContract(credentials);
return createRLNEncoder({
encoder: createEncoder(options),
rlnInstance: this,
index: credentials.membership.treeIndex,
credential: credentials.identity
});
}
public createDecoder(
contentTopic: ContentTopic,
routingInfo: IRoutingInfo
): RLNDecoder<IDecodedMessage> {
return createRLNDecoder({
rlnInstance: this,
decoder: createDecoder(contentTopic, routingInfo)
});
}
public static async loadWitnessCalculator(): Promise<WitnessCalculator> {
try {
const url = new URL("./resources/rln.wasm", import.meta.url);

View File

@ -1,56 +0,0 @@
import { assert, expect } from "chai";
import { MerkleRootTracker } from "./root_tracker.js";
describe("js-rln", () => {
it("should track merkle roots and backfill from block number", async function () {
const acceptableRootWindow = 3;
const tracker = new MerkleRootTracker(
acceptableRootWindow,
new Uint8Array([0, 0, 0, 0])
);
expect(tracker.roots()).to.have.length(1);
expect(tracker.buffer()).to.have.length(0);
expect(tracker.roots()[0]).to.deep.equal(new Uint8Array([0, 0, 0, 0]));
for (let i = 1; i <= 30; i++) {
tracker.pushRoot(i, new Uint8Array([0, 0, 0, i]));
}
expect(tracker.roots()).to.have.length(acceptableRootWindow);
expect(tracker.buffer()).to.have.length(20);
assert.sameDeepMembers(tracker.roots(), [
new Uint8Array([0, 0, 0, 30]),
new Uint8Array([0, 0, 0, 29]),
new Uint8Array([0, 0, 0, 28])
]);
// Buffer should keep track of 20 blocks previous to the current valid merkle root window
expect(tracker.buffer()[0]).to.be.eql(new Uint8Array([0, 0, 0, 8]));
expect(tracker.buffer()[19]).to.be.eql(new Uint8Array([0, 0, 0, 27]));
// Remove roots 29 and 30
tracker.backFill(29);
assert.sameDeepMembers(tracker.roots(), [
new Uint8Array([0, 0, 0, 28]),
new Uint8Array([0, 0, 0, 27]),
new Uint8Array([0, 0, 0, 26])
]);
expect(tracker.buffer()).to.have.length(18);
expect(tracker.buffer()[0]).to.be.eql(new Uint8Array([0, 0, 0, 8]));
expect(tracker.buffer()[17]).to.be.eql(new Uint8Array([0, 0, 0, 25]));
// Remove roots from block 15 onwards. These blocks exists within the buffer
tracker.backFill(15);
assert.sameDeepMembers(tracker.roots(), [
new Uint8Array([0, 0, 0, 14]),
new Uint8Array([0, 0, 0, 13]),
new Uint8Array([0, 0, 0, 12])
]);
expect(tracker.buffer()).to.have.length(4);
expect(tracker.buffer()[0]).to.be.eql(new Uint8Array([0, 0, 0, 8]));
expect(tracker.buffer()[3]).to.be.eql(new Uint8Array([0, 0, 0, 11]));
});
});

View File

@ -1,92 +0,0 @@
class RootPerBlock {
public constructor(
public root: Uint8Array,
public blockNumber: number
) {}
}
const maxBufferSize = 20;
export class MerkleRootTracker {
private validMerkleRoots: Array<RootPerBlock> = new Array<RootPerBlock>();
private merkleRootBuffer: Array<RootPerBlock> = new Array<RootPerBlock>();
public constructor(
private acceptableRootWindowSize: number,
initialRoot: Uint8Array
) {
this.pushRoot(0, initialRoot);
}
public backFill(fromBlockNumber: number): void {
if (this.validMerkleRoots.length == 0) return;
let numBlocks = 0;
for (let i = this.validMerkleRoots.length - 1; i >= 0; i--) {
if (this.validMerkleRoots[i].blockNumber >= fromBlockNumber) {
numBlocks++;
}
}
if (numBlocks == 0) return;
const olderBlock = fromBlockNumber < this.validMerkleRoots[0].blockNumber;
// Remove last roots
let rootsToPop = numBlocks;
if (this.validMerkleRoots.length < rootsToPop) {
rootsToPop = this.validMerkleRoots.length;
}
this.validMerkleRoots = this.validMerkleRoots.slice(
0,
this.validMerkleRoots.length - rootsToPop
);
if (this.merkleRootBuffer.length == 0) return;
if (olderBlock) {
const idx = this.merkleRootBuffer.findIndex(
(x) => x.blockNumber == fromBlockNumber
);
if (idx > -1) {
this.merkleRootBuffer = this.merkleRootBuffer.slice(0, idx);
}
}
// Backfill the tree's acceptable roots
let rootsToRestore =
this.acceptableRootWindowSize - this.validMerkleRoots.length;
if (this.merkleRootBuffer.length < rootsToRestore) {
rootsToRestore = this.merkleRootBuffer.length;
}
for (let i = 0; i < rootsToRestore; i++) {
const x = this.merkleRootBuffer.pop();
if (x) this.validMerkleRoots.unshift(x);
}
}
public pushRoot(blockNumber: number, root: Uint8Array): void {
this.validMerkleRoots.push(new RootPerBlock(root, blockNumber));
// Maintain valid merkle root window
if (this.validMerkleRoots.length > this.acceptableRootWindowSize) {
const x = this.validMerkleRoots.shift();
if (x) this.merkleRootBuffer.push(x);
}
// Maintain merkle root buffer
if (this.merkleRootBuffer.length > maxBufferSize) {
this.merkleRootBuffer.shift();
}
}
public roots(): Array<Uint8Array> {
return this.validMerkleRoots.map((x) => x.root);
}
public buffer(): Array<Uint8Array> {
return this.merkleRootBuffer.map((x) => x.root);
}
}

View File

@ -1,56 +1,52 @@
export class BytesUtils {
/**
* Switches endianness of a byte array
* Concatenate Uint8Arrays
* @param input
* @returns concatenation of all Uint8Array received as input
*/
public static switchEndianness(bytes: Uint8Array): Uint8Array {
return new Uint8Array([...bytes].reverse());
}
/**
* Builds a BigInt from a big-endian Uint8Array
* @param bytes The big-endian bytes to convert
* @returns The resulting BigInt in big-endian format
*/
public static buildBigIntFromUint8ArrayBE(bytes: Uint8Array): bigint {
let result = 0n;
for (let i = 0; i < bytes.length; i++) {
result = (result << 8n) + BigInt(bytes[i]);
public static concatenate(...input: Uint8Array[]): Uint8Array {
let totalLength = 0;
for (const arr of input) {
totalLength += arr.length;
}
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of input) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Switches endianness of a bigint value
* @param value The bigint value to switch endianness for
* @returns The bigint value with reversed endianness
* Convert a Uint8Array to a BigInt with configurable input endianness
* @param bytes - The byte array to convert
* @param inputEndianness - Endianness of the input bytes ('big' or 'little')
* @returns BigInt representation of the bytes
*/
public static switchEndiannessBigInt(value: bigint): bigint {
// Convert bigint to byte array
const bytes = [];
let tempValue = value;
while (tempValue > 0n) {
bytes.push(Number(tempValue & 0xffn));
tempValue >>= 8n;
public static toBigInt(
bytes: Uint8Array,
inputEndianness: "big" | "little" = "little"
): bigint {
if (bytes.length === 0) {
return 0n;
}
// Reverse bytes and convert back to bigint
return bytes
.reverse()
.reduce((acc, byte) => (acc << 8n) + BigInt(byte), 0n);
}
// Create a copy to avoid modifying the original array
const workingBytes = new Uint8Array(bytes);
/**
* Converts a big-endian bigint to a 32-byte big-endian Uint8Array
* @param value The big-endian bigint to convert
* @returns A 32-byte big-endian Uint8Array
*/
public static bigIntToUint8Array32BE(value: bigint): Uint8Array {
const bytes = new Uint8Array(32);
for (let i = 31; i >= 0; i--) {
bytes[i] = Number(value & 0xffn);
value >>= 8n;
// Reverse bytes if input is little-endian to work with big-endian internally
if (inputEndianness === "little") {
workingBytes.reverse();
}
return bytes;
// Convert to BigInt
let result = 0n;
for (let i = 0; i < workingBytes.length; i++) {
result = (result << 8n) | BigInt(workingBytes[i]);
}
return result;
}
/**
@ -81,20 +77,6 @@ export class BytesUtils {
return buf;
}
/**
* Fills with zeros to set length
* @param array little endian Uint8Array
* @param length amount to pad
* @returns little endian Uint8Array padded with zeros to set length
*/
public static zeroPadLE(array: Uint8Array, length: number): Uint8Array {
const result = new Uint8Array(length);
for (let i = 0; i < length; i++) {
result[i] = array[i] || 0;
}
return result;
}
// Adapted from https://github.com/feross/buffer
public static checkInt(
buf: Uint8Array,
@ -108,23 +90,4 @@ export class BytesUtils {
throw new RangeError('"value" argument is out of bounds');
if (offset + ext > buf.length) throw new RangeError("Index out of range");
}
/**
* Concatenate Uint8Arrays
* @param input
* @returns concatenation of all Uint8Array received as input
*/
public static concatenate(...input: Uint8Array[]): Uint8Array {
let totalLength = 0;
for (const arr of input) {
totalLength += arr.length;
}
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of input) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
}

View File

@ -0,0 +1,26 @@
import { expect } from "chai";
import { RLNInstance } from "./rln.js";
describe("@waku/rln", () => {
it("should generate the same membership key if the same seed is provided", async function () {
const rlnInstance = await RLNInstance.create();
const seed = "This is a test seed";
const memKeys1 = rlnInstance.zerokit.generateSeededIdentityCredential(seed);
const memKeys2 = rlnInstance.zerokit.generateSeededIdentityCredential(seed);
memKeys1.IDCommitment.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDCommitment[index]);
});
memKeys1.IDNullifier.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDNullifier[index]);
});
memKeys1.IDSecretHash.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDSecretHash[index]);
});
memKeys1.IDTrapdoor.forEach((element, index) => {
expect(element).to.equal(memKeys2.IDTrapdoor[index]);
});
});
});

View File

@ -1,11 +1,8 @@
import type { IRateLimitProof } from "@waku/interfaces";
import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./contract/constants.js";
import { DEFAULT_RATE_LIMIT } from "./contract/constants.js";
import { IdentityCredential } from "./identity.js";
import { Proof, proofToBytes } from "./proof.js";
import { WitnessCalculator } from "./resources/witness_calculator";
import { BytesUtils, dateToEpoch, epochIntToBytes } from "./utils/index.js";
export class Zerokit {
public constructor(
@ -26,226 +23,13 @@ export class Zerokit {
return this._rateLimit;
}
public generateIdentityCredentials(): IdentityCredential {
const memKeys = zerokitRLN.generateExtendedMembershipKey(this.zkRLN); // TODO: rename this function in zerokit rln-wasm
return IdentityCredential.fromBytes(memKeys);
}
public generateSeededIdentityCredential(seed: string): IdentityCredential {
const stringEncoder = new TextEncoder();
const seedBytes = stringEncoder.encode(seed);
// TODO: rename this function in zerokit rln-wasm
const memKeys = zerokitRLN.generateSeededExtendedMembershipKey(
this.zkRLN,
seedBytes
);
return IdentityCredential.fromBytes(memKeys);
}
public insertMember(idCommitment: Uint8Array): void {
zerokitRLN.insertMember(this.zkRLN, idCommitment);
}
public insertMembers(
index: number,
...idCommitments: Array<Uint8Array>
): void {
// serializes a seq of IDCommitments to a byte seq
// the order of serialization is |id_commitment_len<8>|id_commitment<var>|
const idCommitmentLen = BytesUtils.writeUIntLE(
new Uint8Array(8),
idCommitments.length,
0,
8
);
const idCommitmentBytes = BytesUtils.concatenate(
idCommitmentLen,
...idCommitments
);
zerokitRLN.setLeavesFrom(this.zkRLN, index, idCommitmentBytes);
}
public deleteMember(index: number): void {
zerokitRLN.deleteLeaf(this.zkRLN, index);
}
public getMerkleRoot(): Uint8Array {
return zerokitRLN.getRoot(this.zkRLN);
}
public serializeMessage(
uint8Msg: Uint8Array,
memIndex: number,
epoch: Uint8Array,
idKey: Uint8Array,
rateLimit?: number
): Uint8Array {
// calculate message length
const msgLen = BytesUtils.writeUIntLE(
new Uint8Array(8),
uint8Msg.length,
0,
8
);
const memIndexBytes = BytesUtils.writeUIntLE(
new Uint8Array(8),
memIndex,
0,
8
);
const rateLimitBytes = BytesUtils.writeUIntLE(
new Uint8Array(8),
rateLimit ?? this.rateLimit,
0,
8
);
// [ id_key<32> | id_index<8> | epoch<32> | signal_len<8> | signal<var> | rate_limit<8> ]
return BytesUtils.concatenate(
idKey,
memIndexBytes,
epoch,
msgLen,
uint8Msg,
rateLimitBytes
);
}
public async generateRLNProof(
msg: Uint8Array,
index: number,
epoch: Uint8Array | Date | undefined,
idSecretHash: Uint8Array,
rateLimit?: number
): Promise<IRateLimitProof> {
if (epoch === undefined) {
epoch = epochIntToBytes(dateToEpoch(new Date()));
} else if (epoch instanceof Date) {
epoch = epochIntToBytes(dateToEpoch(epoch));
}
const effectiveRateLimit = rateLimit ?? this.rateLimit;
if (epoch.length !== 32) throw new Error("invalid epoch");
if (idSecretHash.length !== 32) throw new Error("invalid id secret hash");
if (index < 0) throw new Error("index must be >= 0");
if (
effectiveRateLimit < RATE_LIMIT_PARAMS.MIN_RATE ||
effectiveRateLimit > RATE_LIMIT_PARAMS.MAX_RATE
) {
throw new Error(
`Rate limit must be between ${RATE_LIMIT_PARAMS.MIN_RATE} and ${RATE_LIMIT_PARAMS.MAX_RATE}`
);
}
const serialized_msg = this.serializeMessage(
msg,
index,
epoch,
idSecretHash,
effectiveRateLimit
);
const rlnWitness = zerokitRLN.getSerializedRLNWitness(
this.zkRLN,
serialized_msg
);
const inputs = zerokitRLN.RLNWitnessToJson(this.zkRLN, rlnWitness);
const calculatedWitness = await this.witnessCalculator.calculateWitness(
inputs,
false
);
const proofBytes = zerokitRLN.generate_rln_proof_with_witness(
this.zkRLN,
calculatedWitness,
rlnWitness
);
return new Proof(proofBytes);
}
public verifyRLNProof(
proof: IRateLimitProof | Uint8Array,
msg: Uint8Array,
rateLimit?: number
): boolean {
let pBytes: Uint8Array;
if (proof instanceof Uint8Array) {
pBytes = proof;
} else {
pBytes = proofToBytes(proof);
}
// calculate message length
const msgLen = BytesUtils.writeUIntLE(new Uint8Array(8), msg.length, 0, 8);
const rateLimitBytes = BytesUtils.writeUIntLE(
new Uint8Array(8),
rateLimit ?? this.rateLimit,
0,
8
);
return zerokitRLN.verifyRLNProof(
this.zkRLN,
BytesUtils.concatenate(pBytes, msgLen, msg, rateLimitBytes)
);
}
public verifyWithRoots(
proof: IRateLimitProof | Uint8Array,
msg: Uint8Array,
roots: Array<Uint8Array>,
rateLimit?: number
): boolean {
let pBytes: Uint8Array;
if (proof instanceof Uint8Array) {
pBytes = proof;
} else {
pBytes = proofToBytes(proof);
}
// calculate message length
const msgLen = BytesUtils.writeUIntLE(new Uint8Array(8), msg.length, 0, 8);
const rateLimitBytes = BytesUtils.writeUIntLE(
new Uint8Array(8),
rateLimit ?? this.rateLimit,
0,
8
);
const rootsBytes = BytesUtils.concatenate(...roots);
return zerokitRLN.verifyWithRoots(
this.zkRLN,
BytesUtils.concatenate(pBytes, msgLen, msg, rateLimitBytes),
rootsBytes
);
}
public verifyWithNoRoot(
proof: IRateLimitProof | Uint8Array,
msg: Uint8Array,
rateLimit?: number
): boolean {
let pBytes: Uint8Array;
if (proof instanceof Uint8Array) {
pBytes = proof;
} else {
pBytes = proofToBytes(proof);
}
// calculate message length
const msgLen = BytesUtils.writeUIntLE(new Uint8Array(8), msg.length, 0, 8);
const rateLimitBytes = BytesUtils.writeUIntLE(
new Uint8Array(8),
rateLimit ?? this.rateLimit,
0,
8
);
return zerokitRLN.verifyWithRoots(
this.zkRLN,
BytesUtils.concatenate(pBytes, msgLen, msg, rateLimitBytes),
new Uint8Array()
);
}
}

View File

@ -95,6 +95,7 @@ describe("QueryOnConnect", () => {
it("should create QueryOnConnect instance with all required parameters", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -108,6 +109,7 @@ describe("QueryOnConnect", () => {
it("should create QueryOnConnect instance without options", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator
@ -120,6 +122,7 @@ describe("QueryOnConnect", () => {
it("should accept empty decoders array", () => {
queryOnConnect = new QueryOnConnect(
[],
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -134,6 +137,7 @@ describe("QueryOnConnect", () => {
beforeEach(() => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -173,6 +177,7 @@ describe("QueryOnConnect", () => {
beforeEach(() => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -224,6 +229,7 @@ describe("QueryOnConnect", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -276,6 +282,7 @@ describe("QueryOnConnect", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -298,6 +305,7 @@ describe("QueryOnConnect", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -320,6 +328,7 @@ describe("QueryOnConnect", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -391,6 +400,7 @@ describe("QueryOnConnect", () => {
const queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -418,6 +428,7 @@ describe("QueryOnConnect", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -473,6 +484,7 @@ describe("QueryOnConnect", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -605,6 +617,7 @@ describe("QueryOnConnect", () => {
queryOnConnect = new QueryOnConnect(
mockDecoders,
() => false,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
@ -750,6 +763,248 @@ describe("QueryOnConnect", () => {
expect(mockQueryGenerator.calledTwice).to.be.true;
});
});
describe("stopIfTrue predicate", () => {
beforeEach(() => {
mockPeerManagerEventEmitter.addEventListener = sinon.stub();
mockWakuEventEmitter.addEventListener = sinon.stub();
});
it("should stop query iteration when stopIfTrue returns true", async () => {
const messages = [
{
hash: new Uint8Array(),
hashStr: "msg1",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([1]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: new Uint8Array(),
hashStr: "stop-hash",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([2]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: new Uint8Array(),
hashStr: "msg3",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([3]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
}
];
// Setup generator to yield 3 pages, stop should occur on page 2
const mockAsyncGenerator = async function* (): AsyncGenerator<
Promise<IDecodedMessage | undefined>[]
> {
yield [Promise.resolve(messages[0])];
yield [Promise.resolve(messages[1])];
yield [Promise.resolve(messages[2])];
};
mockQueryGenerator.returns(mockAsyncGenerator());
const stopPredicate = (msg: IDecodedMessage): boolean =>
msg.hashStr === "stop-hash";
queryOnConnect = new QueryOnConnect(
mockDecoders,
stopPredicate,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
options
);
const receivedMessages: IDecodedMessage[] = [];
queryOnConnect.addEventListener(
QueryOnConnectEvent.MessagesRetrieved,
(event: CustomEvent<IDecodedMessage[]>) => {
receivedMessages.push(...event.detail);
}
);
queryOnConnect.start();
await queryOnConnect["maybeQuery"](mockPeerId);
// Should have received messages from first 2 pages only
expect(receivedMessages).to.have.length(2);
expect(receivedMessages[0].hashStr).to.equal("msg1");
expect(receivedMessages[1].hashStr).to.equal("stop-hash");
});
it("should process all pages when stopIfTrue never returns true", async () => {
const messages = [
{
hash: new Uint8Array(),
hashStr: "msg1",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([1]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: new Uint8Array(),
hashStr: "msg2",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([2]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: new Uint8Array(),
hashStr: "msg3",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([3]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
}
];
const mockAsyncGenerator = async function* (): AsyncGenerator<
Promise<IDecodedMessage | undefined>[]
> {
yield [Promise.resolve(messages[0])];
yield [Promise.resolve(messages[1])];
yield [Promise.resolve(messages[2])];
};
mockQueryGenerator.returns(mockAsyncGenerator());
const stopPredicate = (): boolean => false;
queryOnConnect = new QueryOnConnect(
mockDecoders,
stopPredicate,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
options
);
const receivedMessages: IDecodedMessage[] = [];
queryOnConnect.addEventListener(
QueryOnConnectEvent.MessagesRetrieved,
(event: CustomEvent<IDecodedMessage[]>) => {
receivedMessages.push(...event.detail);
}
);
queryOnConnect.start();
await queryOnConnect["maybeQuery"](mockPeerId);
// Should have received all 3 messages
expect(receivedMessages).to.have.length(3);
});
it("should stop on first message of a page if stopIfTrue matches", async () => {
const messages = [
{
hash: new Uint8Array(),
hashStr: "stop-hash",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([1]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: new Uint8Array(),
hashStr: "msg2",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([2]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: new Uint8Array(),
hashStr: "msg3",
version: 1,
timestamp: new Date(),
contentTopic: "/test/1/content",
pubsubTopic: "/waku/2/default-waku/proto",
payload: new Uint8Array([3]),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
}
];
const mockAsyncGenerator = async function* (): AsyncGenerator<
Promise<IDecodedMessage | undefined>[]
> {
yield [
Promise.resolve(messages[0]),
Promise.resolve(messages[1]),
Promise.resolve(messages[2])
];
};
mockQueryGenerator.returns(mockAsyncGenerator());
const stopPredicate = (msg: IDecodedMessage): boolean =>
msg.hashStr === "stop-hash";
queryOnConnect = new QueryOnConnect(
mockDecoders,
stopPredicate,
mockPeerManagerEventEmitter,
mockWakuEventEmitter,
mockQueryGenerator,
options
);
const receivedMessages: IDecodedMessage[] = [];
queryOnConnect.addEventListener(
QueryOnConnectEvent.MessagesRetrieved,
(event: CustomEvent<IDecodedMessage[]>) => {
receivedMessages.push(...event.detail);
}
);
queryOnConnect.start();
await queryOnConnect["maybeQuery"](mockPeerId);
// Should have received all 3 messages from the page, even though first matched
expect(receivedMessages).to.have.length(3);
expect(receivedMessages[0].hashStr).to.equal("stop-hash");
expect(receivedMessages[1].hashStr).to.equal("msg2");
expect(receivedMessages[2].hashStr).to.equal("msg3");
});
});
});
describe("calculateTimeRange", () => {

View File

@ -17,7 +17,7 @@ import {
const log = new Logger("sdk:query-on-connect");
export const DEFAULT_FORCE_QUERY_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
export const MAX_TIME_RANGE_QUERY_MS = 24 * 60 * 60 * 1000; // 24 hours
export const MAX_TIME_RANGE_QUERY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days (queries are split)
export interface QueryOnConnectOptions {
/**
@ -54,6 +54,7 @@ export class QueryOnConnect<
public constructor(
public decoders: IDecoder<T>[],
public stopIfTrue: (msg: T) => boolean,
private readonly peerManagerEventEmitter: TypedEventEmitter<IPeerManagerEvents>,
private readonly wakuEventEmitter: IWakuEventEmitter,
private readonly _queryGenerator: <T extends IDecodedMessage>(
@ -125,8 +126,13 @@ export class QueryOnConnect<
const messages = (await Promise.all(page)).filter(
(m) => m !== undefined
);
const stop = messages.some((msg: T) => this.stopIfTrue(msg));
// Bundle the messages to help batch process by sds
this.dispatchMessages(messages);
if (stop) {
break;
}
}
// Didn't throw, so it didn't fail

View File

@ -13,7 +13,7 @@ import {
LightPushSDKResult,
QueryRequestParams
} from "@waku/interfaces";
import { ContentMessage } from "@waku/sds";
import { ContentMessage, SyncMessage } from "@waku/sds";
import {
createRoutingInfo,
delay,
@ -176,7 +176,8 @@ describe("Reliable Channel", () => {
expect(messageAcknowledged).to.be.false;
});
it("Outgoing message is possibly acknowledged", async () => {
// TODO: https://github.com/waku-org/js-waku/issues/2648
it.skip("Outgoing message is possibly acknowledged", async () => {
const commonEventEmitter = new TypedEventEmitter<MockWakuEvents>();
const mockWakuNodeAlice = new MockWakuNode(commonEventEmitter);
const mockWakuNodeBob = new MockWakuNode(commonEventEmitter);
@ -418,7 +419,7 @@ describe("Reliable Channel", () => {
"MyChannel",
"alice",
[],
1,
1n,
undefined,
message
);
@ -531,7 +532,7 @@ describe("Reliable Channel", () => {
"testChannel",
"testSender",
[],
1,
1n,
undefined,
messagePayload
);
@ -599,7 +600,7 @@ describe("Reliable Channel", () => {
"testChannel",
"testSender",
[],
1,
1n,
undefined,
message1Payload
);
@ -609,7 +610,7 @@ describe("Reliable Channel", () => {
"testChannel",
"testSender",
[],
2,
2n,
undefined,
message2Payload
);
@ -677,4 +678,456 @@ describe("Reliable Channel", () => {
expect(queryGeneratorStub.called).to.be.true;
});
});
describe("stopIfTrue Integration with QueryOnConnect", () => {
let mockWakuNode: MockWakuNode;
let encoder: IEncoder;
let decoder: IDecoder<IDecodedMessage>;
let mockPeerManagerEvents: TypedEventEmitter<any>;
let queryGeneratorStub: sinon.SinonStub;
let mockPeerId: PeerId;
beforeEach(async () => {
mockWakuNode = new MockWakuNode();
mockPeerManagerEvents = new TypedEventEmitter();
(mockWakuNode as any).peerManager = {
events: mockPeerManagerEvents
};
encoder = createEncoder({
contentTopic: TEST_CONTENT_TOPIC,
routingInfo: TEST_ROUTING_INFO
});
decoder = createDecoder(TEST_CONTENT_TOPIC, TEST_ROUTING_INFO);
queryGeneratorStub = sinon.stub();
mockWakuNode.store = {
queryGenerator: queryGeneratorStub
} as any;
mockPeerId = {
toString: () => "QmTestPeerId"
} as unknown as PeerId;
});
it("should stop query when sync message from same channel is found", async () => {
const channelId = "testChannel";
const senderId = "testSender";
// Create messages: one from different channel, one sync from same channel, one more
const sdsMessageDifferentChannel = new ContentMessage(
"msg1",
"differentChannel",
senderId,
[],
1n,
undefined,
utf8ToBytes("different channel")
);
const sdsSyncMessage = new SyncMessage(
"sync-msg-id",
channelId,
senderId,
[],
2n,
undefined,
undefined
);
const sdsMessageAfterSync = new ContentMessage(
"msg3",
channelId,
senderId,
[],
3n,
undefined,
utf8ToBytes("after sync")
);
const messages: IDecodedMessage[] = [
{
hash: hexToBytes("1111"),
hashStr: "1111",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsMessageDifferentChannel.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: hexToBytes("2222"),
hashStr: "2222",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsSyncMessage.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: hexToBytes("3333"),
hashStr: "3333",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsMessageAfterSync.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
}
];
// Setup generator to yield 3 messages, but should stop after 2nd
queryGeneratorStub.callsFake(async function* () {
yield [Promise.resolve(messages[0])];
yield [Promise.resolve(messages[1])];
yield [Promise.resolve(messages[2])];
});
const reliableChannel = await ReliableChannel.create(
mockWakuNode,
channelId,
senderId,
encoder,
decoder
);
await delay(50);
// Trigger query on connect
mockPeerManagerEvents.dispatchEvent(
new CustomEvent("store:connect", { detail: mockPeerId })
);
await delay(200);
// queryGenerator should have been called
expect(queryGeneratorStub.called).to.be.true;
// The query should have stopped after finding sync message from same channel
expect(reliableChannel).to.not.be.undefined;
});
it("should stop query on content message from same channel", async () => {
const channelId = "testChannel";
const senderId = "testSender";
const sdsContentMessage = new ContentMessage(
"msg1",
channelId,
senderId,
[{ messageId: "previous-msg-id" }],
1n,
undefined,
utf8ToBytes("content message")
);
const sdsMessageAfter = new ContentMessage(
"msg2",
channelId,
senderId,
[],
2n,
undefined,
utf8ToBytes("after content")
);
const messages: IDecodedMessage[] = [
{
hash: hexToBytes("1111"),
hashStr: "1111",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsContentMessage.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: hexToBytes("2222"),
hashStr: "2222",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsMessageAfter.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
}
];
let pagesYielded = 0;
queryGeneratorStub.callsFake(async function* () {
pagesYielded++;
yield [Promise.resolve(messages[0])];
pagesYielded++;
yield [Promise.resolve(messages[1])];
});
const reliableChannel = await ReliableChannel.create(
mockWakuNode,
channelId,
senderId,
encoder,
decoder
);
await delay(50);
mockPeerManagerEvents.dispatchEvent(
new CustomEvent("store:connect", { detail: mockPeerId })
);
await delay(200);
expect(queryGeneratorStub.called).to.be.true;
expect(reliableChannel).to.not.be.undefined;
// Should have stopped after first page with content message
expect(pagesYielded).to.equal(1);
});
it("should continue query when messages are from different channels", async () => {
const channelId = "testChannel";
const senderId = "testSender";
const sdsMessageDifferent1 = new ContentMessage(
"msg1",
"differentChannel1",
senderId,
[],
1n,
undefined,
utf8ToBytes("different 1")
);
const sdsMessageDifferent2 = new ContentMessage(
"msg2",
"differentChannel2",
senderId,
[],
2n,
undefined,
utf8ToBytes("different 2")
);
const sdsMessageDifferent3 = new ContentMessage(
"msg3",
"differentChannel3",
senderId,
[],
3n,
undefined,
utf8ToBytes("different 3")
);
const messages: IDecodedMessage[] = [
{
hash: hexToBytes("1111"),
hashStr: "1111",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsMessageDifferent1.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: hexToBytes("2222"),
hashStr: "2222",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsMessageDifferent2.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
},
{
hash: hexToBytes("3333"),
hashStr: "3333",
version: 1,
timestamp: new Date(),
contentTopic: TEST_CONTENT_TOPIC,
pubsubTopic: decoder.pubsubTopic,
payload: sdsMessageDifferent3.encode(),
rateLimitProof: undefined,
ephemeral: false,
meta: undefined
}
];
let pagesYielded = 0;
queryGeneratorStub.callsFake(async function* () {
pagesYielded++;
yield [Promise.resolve(messages[0])];
pagesYielded++;
yield [Promise.resolve(messages[1])];
pagesYielded++;
yield [Promise.resolve(messages[2])];
});
const reliableChannel = await ReliableChannel.create(
mockWakuNode,
channelId,
senderId,
encoder,
decoder
);
await delay(50);
mockPeerManagerEvents.dispatchEvent(
new CustomEvent("store:connect", { detail: mockPeerId })
);
await delay(200);
expect(queryGeneratorStub.called).to.be.true;
expect(reliableChannel).to.not.be.undefined;
// Should have processed all pages since no matching channel
expect(pagesYielded).to.equal(3);
});
});
describe("isChannelMessageWithCausalHistory predicate", () => {
let mockWakuNode: MockWakuNode;
let reliableChannel: ReliableChannel<IDecodedMessage>;
let encoder: IEncoder;
let decoder: IDecoder<IDecodedMessage>;
beforeEach(async () => {
mockWakuNode = new MockWakuNode();
encoder = createEncoder({
contentTopic: TEST_CONTENT_TOPIC,
routingInfo: TEST_ROUTING_INFO
});
decoder = createDecoder(TEST_CONTENT_TOPIC, TEST_ROUTING_INFO);
reliableChannel = await ReliableChannel.create(
mockWakuNode,
"testChannel",
"testSender",
encoder,
decoder,
{ queryOnConnect: false }
);
});
it("should return false for malformed SDS messages", () => {
const msg = {
payload: new Uint8Array([1, 2, 3])
} as IDecodedMessage;
const result = reliableChannel["isChannelMessageWithCausalHistory"](msg);
expect(result).to.be.false;
});
it("should return false for different channelId", () => {
const sdsMsg = new ContentMessage(
"msg1",
"differentChannel",
"sender",
[],
1n,
undefined,
utf8ToBytes("content")
);
const msg = {
payload: sdsMsg.encode()
} as IDecodedMessage;
const result = reliableChannel["isChannelMessageWithCausalHistory"](msg);
expect(result).to.be.false;
});
it("should return false for sync message without causal history", () => {
const syncMsg = new SyncMessage(
"sync-msg-id",
"testChannel",
"sender",
[],
1n,
undefined,
undefined
);
const msg = {
payload: syncMsg.encode()
} as IDecodedMessage;
const result = reliableChannel["isChannelMessageWithCausalHistory"](msg);
expect(result).to.be.false;
});
it("should return false for content message without causal history", () => {
const contentMsg = new ContentMessage(
"msg1",
"testChannel",
"sender",
[],
1n,
undefined,
utf8ToBytes("content")
);
const msg = {
payload: contentMsg.encode()
} as IDecodedMessage;
const result = reliableChannel["isChannelMessageWithCausalHistory"](msg);
expect(result).to.be.false;
});
it("should return true for message with causal history", () => {
const contentMsg = new ContentMessage(
"msg1",
"testChannel",
"sender",
[{ messageId: "previous-msg-id" }],
1n,
undefined,
utf8ToBytes("content")
);
const msg = {
payload: contentMsg.encode()
} as IDecodedMessage;
const result = reliableChannel["isChannelMessageWithCausalHistory"](msg);
expect(result).to.be.true;
});
it("should return true for sync message with causal history", () => {
const syncMsg = new SyncMessage(
"sync-msg-id",
"testChannel",
"sender",
[{ messageId: "previous-msg-id" }],
1n,
undefined,
undefined
);
const msg = {
payload: syncMsg.encode()
} as IDecodedMessage;
const result = reliableChannel["isChannelMessageWithCausalHistory"](msg);
expect(result).to.be.true;
});
});
});

View File

@ -185,9 +185,9 @@ export class ReliableChannel<
peerManagerEvents !== undefined &&
(options?.queryOnConnect ?? true)
) {
log.info("auto-query enabled");
this.queryOnConnect = new QueryOnConnect(
[this.decoder],
this.isChannelMessageWithCausalHistory.bind(this),
peerManagerEvents,
node.events,
this._retrieve.bind(this)
@ -580,6 +580,21 @@ export class ReliableChannel<
this.messageChannel.sweepOutgoingBuffer();
}
private isChannelMessageWithCausalHistory(msg: T): boolean {
// TODO: we do end-up decoding messages twice as this is used to stop store queries.
const sdsMessage = SdsMessage.decode(msg.payload);
if (!sdsMessage) {
return false;
}
if (sdsMessage.channelId !== this.messageChannel.channelId) {
return false;
}
return sdsMessage.causalHistory && sdsMessage.causalHistory.length > 0;
}
private setupEventListeners(): void {
this.messageChannel.addEventListener(
MessageChannelEvent.OutMessageSent,

View File

@ -187,7 +187,8 @@ describe("Reliable Channel: Encryption", () => {
expect(messageAcknowledged).to.be.false;
});
it("Outgoing message is possibly acknowledged", async () => {
// TODO: https://github.com/waku-org/js-waku/issues/2648
it.skip("Outgoing message is possibly acknowledged", async () => {
const commonEventEmitter = new TypedEventEmitter<MockWakuEvents>();
const mockWakuNodeAlice = new MockWakuNode(commonEventEmitter);
const mockWakuNodeBob = new MockWakuNode(commonEventEmitter);

View File

@ -56,6 +56,19 @@ describe("Reliable Channel: Sync", () => {
}
);
// Send a message to have a history
const sentMsgId = reliableChannel.send(utf8ToBytes("some message"));
let messageSent = false;
reliableChannel.addEventListener("message-sent", (event) => {
if (event.detail === sentMsgId) {
messageSent = true;
}
});
while (!messageSent) {
await delay(50);
}
let syncMessageSent = false;
reliableChannel.messageChannel.addEventListener(
MessageChannelEvent.OutSyncSent,
@ -131,6 +144,19 @@ describe("Reliable Channel: Sync", () => {
return 1;
}; // will wait a full second
// Send a message to have a history
const sentMsgId = reliableChannelAlice.send(utf8ToBytes("some message"));
let messageSent = false;
reliableChannelAlice.addEventListener("message-sent", (event) => {
if (event.detail === sentMsgId) {
messageSent = true;
}
});
while (!messageSent) {
await delay(50);
}
let syncMessageSent = false;
reliableChannelBob.messageChannel.addEventListener(
MessageChannelEvent.OutSyncSent,
@ -191,6 +217,19 @@ describe("Reliable Channel: Sync", () => {
return 1;
}; // will wait a full second
// Send a message to have a history
const sentMsgId = reliableChannelAlice.send(utf8ToBytes("some message"));
let messageSent = false;
reliableChannelAlice.addEventListener("message-sent", (event) => {
if (event.detail === sentMsgId) {
messageSent = true;
}
});
while (!messageSent) {
await delay(50);
}
let syncMessageSent = false;
reliableChannelBob.messageChannel.addEventListener(
MessageChannelEvent.OutSyncSent,
@ -232,6 +271,19 @@ describe("Reliable Channel: Sync", () => {
return 1;
}; // will wait a full second
// Send a message to have a history
const sentMsgId = reliableChannel.send(utf8ToBytes("some message"));
let messageSent = false;
reliableChannel.addEventListener("message-sent", (event) => {
if (event.detail === sentMsgId) {
messageSent = true;
}
});
while (!messageSent) {
await delay(50);
}
let syncMessageSent = false;
reliableChannel.messageChannel.addEventListener(
MessageChannelEvent.OutSyncSent,
@ -273,6 +325,19 @@ describe("Reliable Channel: Sync", () => {
return 1;
}; // will wait a full second
// Send a message to have a history
const sentMsgId = reliableChannel.send(utf8ToBytes("some message"));
let messageSent = false;
reliableChannel.addEventListener("message-sent", (event) => {
if (event.detail === sentMsgId) {
messageSent = true;
}
});
while (!messageSent) {
await delay(50);
}
let syncMessageSent = false;
reliableChannel.messageChannel.addEventListener(
MessageChannelEvent.OutSyncSent,

View File

@ -0,0 +1,56 @@
import { expect } from "chai";
import { lamportTimestampIncrement } from "./message_channel.js";
describe("lamportTimestampIncrement", () => {
it("should increment timestamp by 1 when current time is not greater", () => {
const futureTimestamp = BigInt(Date.now()) + 1000n;
const result = lamportTimestampIncrement(futureTimestamp);
expect(result).to.equal(futureTimestamp + 1n);
});
it("should use current time when it's greater than incremented timestamp", () => {
const pastTimestamp = BigInt(Date.now()) - 1000n;
const result = lamportTimestampIncrement(pastTimestamp);
const now = BigInt(Date.now());
// Result should be at least as large as now (within small tolerance for test execution time)
expect(result >= now - 10n).to.be.true;
expect(result <= now + 10n).to.be.true;
});
it("should handle timestamp equal to current time", () => {
const currentTimestamp = BigInt(Date.now());
const result = lamportTimestampIncrement(currentTimestamp);
// Should increment by 1 since now is likely not greater than current + 1
expect(result >= currentTimestamp + 1n).to.be.true;
});
it("should ensure monotonic increase", () => {
let timestamp = BigInt(Date.now()) + 5000n;
const results: bigint[] = [];
for (let i = 0; i < 5; i++) {
timestamp = lamportTimestampIncrement(timestamp);
results.push(timestamp);
}
// Verify all timestamps are strictly increasing
for (let i = 1; i < results.length; i++) {
expect(results[i] > results[i - 1]).to.be.true;
}
});
it("should handle very large timestamps", () => {
const largeTimestamp = BigInt(Number.MAX_SAFE_INTEGER) * 1000n;
const result = lamportTimestampIncrement(largeTimestamp);
expect(result).to.equal(largeTimestamp + 1n);
});
it("should jump to current time when timestamp is far in the past", () => {
const veryOldTimestamp = 1000n; // Very old timestamp (1 second after epoch)
const result = lamportTimestampIncrement(veryOldTimestamp);
const now = BigInt(Date.now());
expect(result >= now - 10n).to.be.true;
expect(result <= now + 10n).to.be.true;
});
});

View File

@ -18,7 +18,7 @@ describe("Message serialization", () => {
"my-channel",
"me",
[],
0,
0n,
bloomFilter.toBytes(),
undefined
);
@ -42,7 +42,7 @@ describe("Message serialization", () => {
"my-channel",
"me",
[{ messageId: depMessageId, retrievalHint: depRetrievalHint }],
0,
0n,
undefined,
undefined
);
@ -63,7 +63,7 @@ describe("ContentMessage comparison with < operator", () => {
"channel",
"sender",
[],
100, // Lower timestamp
100n, // Lower timestamp
undefined,
new Uint8Array([1])
);
@ -73,7 +73,7 @@ describe("ContentMessage comparison with < operator", () => {
"channel",
"sender",
[],
200, // Higher timestamp
200n, // Higher timestamp
undefined,
new Uint8Array([2])
);
@ -89,7 +89,7 @@ describe("ContentMessage comparison with < operator", () => {
"channel",
"sender",
[],
100, // Same timestamp
100n, // Same timestamp
undefined,
new Uint8Array([1])
);
@ -99,7 +99,7 @@ describe("ContentMessage comparison with < operator", () => {
"channel",
"sender",
[],
100, // Same timestamp
100n, // Same timestamp
undefined,
new Uint8Array([2])
);

View File

@ -14,7 +14,7 @@ export class Message implements proto_sds_message.SdsMessage {
public channelId: string,
public senderId: string,
public causalHistory: proto_sds_message.HistoryEntry[],
public lamportTimestamp?: number | undefined,
public lamportTimestamp?: bigint | undefined,
public bloomFilter?: Uint8Array<ArrayBufferLike> | undefined,
public content?: Uint8Array<ArrayBufferLike> | undefined,
/**
@ -30,56 +30,60 @@ export class Message implements proto_sds_message.SdsMessage {
public static decode(
data: Uint8Array
): undefined | ContentMessage | SyncMessage | EphemeralMessage {
const {
messageId,
channelId,
senderId,
causalHistory,
lamportTimestamp,
bloomFilter,
content
} = proto_sds_message.SdsMessage.decode(data);
if (testContentMessage({ lamportTimestamp, content })) {
return new ContentMessage(
try {
const {
messageId,
channelId,
senderId,
causalHistory,
lamportTimestamp!,
lamportTimestamp,
bloomFilter,
content!
);
}
content
} = proto_sds_message.SdsMessage.decode(data);
if (testEphemeralMessage({ lamportTimestamp, content })) {
return new EphemeralMessage(
messageId,
channelId,
senderId,
causalHistory,
undefined,
bloomFilter,
content!
);
}
if (testContentMessage({ lamportTimestamp, content })) {
return new ContentMessage(
messageId,
channelId,
senderId,
causalHistory,
lamportTimestamp!,
bloomFilter,
content!
);
}
if (testSyncMessage({ lamportTimestamp, content })) {
return new SyncMessage(
messageId,
channelId,
senderId,
causalHistory,
lamportTimestamp!,
bloomFilter,
undefined
if (testEphemeralMessage({ lamportTimestamp, content })) {
return new EphemeralMessage(
messageId,
channelId,
senderId,
causalHistory,
undefined,
bloomFilter,
content!
);
}
if (testSyncMessage({ lamportTimestamp, content })) {
return new SyncMessage(
messageId,
channelId,
senderId,
causalHistory,
lamportTimestamp!,
bloomFilter,
undefined
);
}
log.error(
"message received was of unknown type",
lamportTimestamp,
content
);
} catch (err) {
log.error("failed to decode sds message", err);
}
log.error(
"message received was of unknown type",
lamportTimestamp,
content
);
return undefined;
}
}
@ -90,7 +94,7 @@ export class SyncMessage extends Message {
public channelId: string,
public senderId: string,
public causalHistory: proto_sds_message.HistoryEntry[],
public lamportTimestamp: number,
public lamportTimestamp: bigint,
public bloomFilter: Uint8Array<ArrayBufferLike> | undefined,
public content: undefined,
/**
@ -112,12 +116,12 @@ export class SyncMessage extends Message {
}
function testSyncMessage(message: {
lamportTimestamp?: number;
lamportTimestamp?: bigint;
content?: Uint8Array;
}): boolean {
return Boolean(
"lamportTimestamp" in message &&
typeof message.lamportTimestamp === "number" &&
typeof message.lamportTimestamp === "bigint" &&
(message.content === undefined || message.content.length === 0)
);
}
@ -165,7 +169,7 @@ export function isEphemeralMessage(
}
function testEphemeralMessage(message: {
lamportTimestamp?: number;
lamportTimestamp?: bigint;
content?: Uint8Array;
}): boolean {
return Boolean(
@ -182,7 +186,7 @@ export class ContentMessage extends Message {
public channelId: string,
public senderId: string,
public causalHistory: proto_sds_message.HistoryEntry[],
public lamportTimestamp: number,
public lamportTimestamp: bigint,
public bloomFilter: Uint8Array<ArrayBufferLike> | undefined,
public content: Uint8Array<ArrayBufferLike>,
/**
@ -222,12 +226,12 @@ export function isContentMessage(
}
function testContentMessage(message: {
lamportTimestamp?: number;
lamportTimestamp?: bigint;
content?: Uint8Array;
}): message is { lamportTimestamp: number; content: Uint8Array } {
}): message is { lamportTimestamp: bigint; content: Uint8Array } {
return Boolean(
"lamportTimestamp" in message &&
typeof message.lamportTimestamp === "number" &&
typeof message.lamportTimestamp === "bigint" &&
message.content &&
message.content.length
);

View File

@ -75,7 +75,7 @@ describe("MessageChannel", function () {
const timestampBefore = channelA["lamportTimestamp"];
await sendMessage(channelA, utf8ToBytes("message"), callback);
const timestampAfter = channelA["lamportTimestamp"];
expect(timestampAfter).to.equal(timestampBefore + 1);
expect(timestampAfter).to.equal(timestampBefore + 1n);
});
it("should push the message to the outgoing buffer", async () => {
@ -95,7 +95,7 @@ describe("MessageChannel", function () {
it("should insert message id into causal history", async () => {
const payload = utf8ToBytes("message");
const expectedTimestamp = channelA["lamportTimestamp"] + 1;
const expectedTimestamp = channelA["lamportTimestamp"] + 1n;
const messageId = MessageChannel.getMessageId(payload);
await sendMessage(channelA, payload, callback);
const messageIdLog = channelA["localHistory"] as ILocalHistory;
@ -181,7 +181,7 @@ describe("MessageChannel", function () {
return { success: true };
});
const timestampAfter = channelA["lamportTimestamp"];
expect(timestampAfter).to.equal(timestampBefore + 1);
expect(timestampAfter).to.equal(timestampBefore + 1n);
});
// TODO: test is failing in CI, investigate in https://github.com/waku-org/js-waku/issues/2648
@ -201,7 +201,9 @@ describe("MessageChannel", function () {
});
}
const timestampAfter = testChannelA["lamportTimestamp"];
expect(timestampAfter - timestampBefore).to.equal(messagesB.length);
expect(timestampAfter - timestampBefore).to.equal(
BigInt(messagesB.length)
);
});
// TODO: test is failing in CI, investigate in https://github.com/waku-org/js-waku/issues/2648
@ -228,7 +230,7 @@ describe("MessageChannel", function () {
const expectedLength = messagesA.length + messagesB.length;
expect(channelA["lamportTimestamp"]).to.equal(
aTimestampBefore + expectedLength
aTimestampBefore + BigInt(expectedLength)
);
expect(channelA["lamportTimestamp"]).to.equal(
channelB["lamportTimestamp"]
@ -293,7 +295,7 @@ describe("MessageChannel", function () {
channelA.channelId,
"not-alice",
[],
1,
1n,
undefined,
payload,
testRetrievalHint
@ -335,7 +337,7 @@ describe("MessageChannel", function () {
channelA.channelId,
"bob",
[],
startTimestamp + 3, // Higher timestamp
startTimestamp + 3n, // Higher timestamp
undefined,
message3Payload
)
@ -349,7 +351,7 @@ describe("MessageChannel", function () {
channelA.channelId,
"carol",
[],
startTimestamp + 2, // Middle timestamp
startTimestamp + 2n, // Middle timestamp
undefined,
message2Payload
)
@ -363,7 +365,7 @@ describe("MessageChannel", function () {
const first = localHistory.findIndex(
({ messageId, lamportTimestamp }) => {
return (
messageId === message1Id && lamportTimestamp === startTimestamp + 1
messageId === message1Id && lamportTimestamp === startTimestamp + 1n
);
}
);
@ -372,7 +374,7 @@ describe("MessageChannel", function () {
const second = localHistory.findIndex(
({ messageId, lamportTimestamp }) => {
return (
messageId === message2Id && lamportTimestamp === startTimestamp + 2
messageId === message2Id && lamportTimestamp === startTimestamp + 2n
);
}
);
@ -381,7 +383,7 @@ describe("MessageChannel", function () {
const third = localHistory.findIndex(
({ messageId, lamportTimestamp }) => {
return (
messageId === message3Id && lamportTimestamp === startTimestamp + 3
messageId === message3Id && lamportTimestamp === startTimestamp + 3n
);
}
);
@ -404,7 +406,7 @@ describe("MessageChannel", function () {
channelA.channelId,
"bob",
[],
5, // Same timestamp
5n, // Same timestamp
undefined,
message2Payload
)
@ -417,7 +419,7 @@ describe("MessageChannel", function () {
channelA.channelId,
"carol",
[],
5, // Same timestamp
5n, // Same timestamp
undefined,
message1Payload
)
@ -432,14 +434,14 @@ describe("MessageChannel", function () {
const first = localHistory.findIndex(
({ messageId, lamportTimestamp }) => {
return messageId === expectedOrder[0] && lamportTimestamp == 5;
return messageId === expectedOrder[0] && lamportTimestamp == 5n;
}
);
expect(first).to.eq(0);
const second = localHistory.findIndex(
({ messageId, lamportTimestamp }) => {
return messageId === expectedOrder[1] && lamportTimestamp == 5;
return messageId === expectedOrder[1] && lamportTimestamp == 5n;
}
);
expect(second).to.eq(1);
@ -645,11 +647,12 @@ describe("MessageChannel", function () {
});
// And be sends a sync message
await channelB.pushOutgoingSyncMessage(async (message) => {
const res = await channelB.pushOutgoingSyncMessage(async (message) => {
await receiveMessage(channelA, message);
return true;
});
expect(res).to.be.true;
expect(messageAcked).to.be.true;
});
});
@ -1087,17 +1090,41 @@ describe("MessageChannel", function () {
causalHistorySize: 2
});
channelB = new MessageChannel(channelId, "bob", { causalHistorySize: 2 });
const message = utf8ToBytes("first message in channel");
channelA["localHistory"].push(
new ContentMessage(
MessageChannel.getMessageId(message),
"MyChannel",
"alice",
[],
1n,
undefined,
message
)
);
});
it("should be sent with empty content", async () => {
await channelA.pushOutgoingSyncMessage(async (message) => {
const res = await channelA.pushOutgoingSyncMessage(async (message) => {
expect(message.content).to.be.undefined;
return true;
});
expect(res).to.be.true;
});
it("should not be sent when there is no history", async () => {
const channelC = new MessageChannel(channelId, "carol", {
causalHistorySize: 2
});
const res = await channelC.pushOutgoingSyncMessage(async (_msg) => {
throw "callback was called when it's not expected";
});
expect(res).to.be.false;
});
it("should not be added to outgoing buffer, bloom filter, or local log", async () => {
await channelA.pushOutgoingSyncMessage();
const res = await channelA.pushOutgoingSyncMessage();
expect(res).to.be.true;
const outgoingBuffer = channelA["outgoingBuffer"] as Message[];
expect(outgoingBuffer.length).to.equal(0);
@ -1108,15 +1135,16 @@ describe("MessageChannel", function () {
).to.equal(false);
const localLog = channelA["localHistory"];
expect(localLog.length).to.equal(0);
expect(localLog.length).to.equal(1); // beforeEach adds one message
});
it("should not be delivered", async () => {
const timestampBefore = channelB["lamportTimestamp"];
await channelA.pushOutgoingSyncMessage(async (message) => {
const res = await channelA.pushOutgoingSyncMessage(async (message) => {
await receiveMessage(channelB, message);
return true;
});
expect(res).to.be.true;
const timestampAfter = channelB["lamportTimestamp"];
expect(timestampAfter).to.equal(timestampBefore);
@ -1130,20 +1158,23 @@ describe("MessageChannel", function () {
});
it("should update ack status of messages in outgoing buffer", async () => {
const channelC = new MessageChannel(channelId, "carol", {
causalHistorySize: 2
});
for (const m of messagesA) {
await sendMessage(channelA, utf8ToBytes(m), async (message) => {
await sendMessage(channelC, utf8ToBytes(m), async (message) => {
await receiveMessage(channelB, message);
return { success: true };
});
}
await sendSyncMessage(channelB, async (message) => {
await receiveMessage(channelA, message);
await receiveMessage(channelC, message);
return true;
});
const causalHistorySize = channelA["causalHistorySize"];
const outgoingBuffer = channelA["outgoingBuffer"] as Message[];
const causalHistorySize = channelC["causalHistorySize"];
const outgoingBuffer = channelC["outgoingBuffer"] as Message[];
expect(outgoingBuffer.length).to.equal(
messagesA.length - causalHistorySize
);

View File

@ -56,7 +56,7 @@ export type ILocalHistory = Pick<
export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
public readonly channelId: ChannelId;
public readonly senderId: SenderId;
private lamportTimestamp: number;
private lamportTimestamp: bigint;
private filter: DefaultBloomFilter;
private outgoingBuffer: ContentMessage[];
private possibleAcks: Map<MessageId, number>;
@ -95,9 +95,8 @@ export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
super();
this.channelId = channelId;
this.senderId = senderId;
// SDS RFC says to use nanoseconds, but current time in nanosecond is > Number.MAX_SAFE_INTEGER
// So instead we are using milliseconds and proposing a spec change (TODO)
this.lamportTimestamp = Date.now();
// Initialize channel lamport timestamp to current time in milliseconds.
this.lamportTimestamp = BigInt(Date.now());
this.filter = new DefaultBloomFilter(DEFAULT_BLOOM_FILTER_OPTIONS);
this.outgoingBuffer = [];
this.possibleAcks = new Map();
@ -369,7 +368,7 @@ export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
public async pushOutgoingSyncMessage(
callback?: (message: SyncMessage) => Promise<boolean>
): Promise<boolean> {
this.lamportTimestamp++;
this.lamportTimestamp = lamportTimestampIncrement(this.lamportTimestamp);
const message = new SyncMessage(
// does not need to be secure randomness
`sync-${Math.random().toString(36).substring(2)}`,
@ -385,6 +384,14 @@ export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
undefined
);
if (!message.causalHistory || message.causalHistory.length === 0) {
log.info(
this.senderId,
"no causal history in sync message, aborting sending"
);
return false;
}
if (callback) {
try {
await callback(message);
@ -401,7 +408,8 @@ export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
throw error;
}
}
return false;
// No problem encountered so returning true
return true;
}
private _pushIncomingMessage(message: Message): void {
@ -526,7 +534,7 @@ export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
retrievalHint?: Uint8Array;
}>
): Promise<void> {
this.lamportTimestamp++;
this.lamportTimestamp = lamportTimestampIncrement(this.lamportTimestamp);
const messageId = MessageChannel.getMessageId(payload);
@ -724,3 +732,12 @@ export class MessageChannel extends TypedEventEmitter<MessageChannelEvents> {
});
}
}
export function lamportTimestampIncrement(lamportTimestamp: bigint): bigint {
const now = BigInt(Date.now());
lamportTimestamp++;
if (now > lamportTimestamp) {
return now;
}
return lamportTimestamp;
}