From 7c91cda9b03e29e3de11c83325a02df91ec34e5d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 1 Oct 2026 10:35:12 +0100 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Propter=20polar=20bear:=20canonical=20prover=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use four-byte slots, authenticated packed meter state, and a clean documented implementation. Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- .dockerignore | 37 +- .env.example | 40 +- .github/workflows/build-images.yml | 169 -------- .github/workflows/test.yml | 28 ++ .gitignore | 38 +- .vscode/settings.json | 43 --- Cargo.lock | 602 ++++++++++++++++------------- Cargo.toml | 5 +- Dockerfile | 24 -- README.md | 176 ++++----- contracts/Cargo.toml | 14 - contracts/src/lib.rs | 292 -------------- diesel.toml | 9 - docker-compose.yml | 53 --- fixtures/encoding.json | 6 + fixtures/input.json | 59 +++ fixtures/transition.json | 133 +++++++ host/Cargo.toml | 21 + host/src/budget.rs | 136 +++++++ host/src/main.rs | 555 ++++++++++++++++++++++++++ lib/Cargo.toml | 11 +- lib/src/accounting.rs | 112 ++++++ lib/src/lib.rs | 232 +---------- lib/src/transition.rs | 163 ++++++++ lib/src/util.rs | 150 ------- lib/tests/protocol.rs | 122 ++++++ node/Cargo.toml | 21 - node/build.rs | 10 - node/src/lib.rs | 74 ---- node/src/main.rs | 602 ----------------------------- program/Cargo.toml | 16 +- program/src/main.rs | 155 +------- rust-toolchain | 3 - rust-toolchain.toml | 3 + streamr-client/.env.example | 7 +- streamr-client/index.js | 2 +- tests/data_test.rs | 5 - 37 files changed, 1805 insertions(+), 2323 deletions(-) delete mode 100644 .github/workflows/build-images.yml create mode 100644 .github/workflows/test.yml delete mode 100644 .vscode/settings.json delete mode 100644 Dockerfile delete mode 100644 contracts/Cargo.toml delete mode 100644 contracts/src/lib.rs delete mode 100644 diesel.toml delete mode 100644 docker-compose.yml create mode 100644 fixtures/encoding.json create mode 100644 fixtures/input.json create mode 100644 fixtures/transition.json create mode 100644 host/Cargo.toml create mode 100644 host/src/budget.rs create mode 100644 host/src/main.rs create mode 100644 lib/src/accounting.rs create mode 100644 lib/src/transition.rs delete mode 100644 lib/src/util.rs create mode 100644 lib/tests/protocol.rs delete mode 100644 node/Cargo.toml delete mode 100644 node/build.rs delete mode 100644 node/src/lib.rs delete mode 100644 node/src/main.rs delete mode 100644 rust-toolchain create mode 100644 rust-toolchain.toml delete mode 100644 tests/data_test.rs diff --git a/.dockerignore b/.dockerignore index 94be448..eb507bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,33 +1,6 @@ -# Include any files or directories that you don't want to be copied to your -# container here (e.g., local build artifacts, temporary files, etc.). -# -# For more help, visit the .dockerignore file reference guide at -# https://docs.docker.com/go/build-context-dockerignore/ - -**/.DS_Store -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.github -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/charts -**/docker-compose* -**/compose.y*ml -**/Dockerfile* +.git +target +elf **/node_modules -**/npm-debug.log -**/secrets.dev.yaml -**/values.dev.yaml -/bin -/target -LICENSE -README.md +**/.env +prover-output* diff --git a/.env.example b/.env.example index addd6d9..07f75b0 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,29 @@ - -PROVER_NODE_PORT= -STREAMR_CLIENT_PORT= - -NETWORK_RPC_URL=https://rpc.mainnet.succinct.xyz -RPC_URL=https://eth-sepolia.g.alchemy.com/v2/URjQnzNCUHumxPFL8VDoFBmpX4uqL6X8 +# Read-only plan. Hash-pinned eth_call (EIP-1898) is required. +RPC_URL=https://your-ethereum-rpc +ROLLUP_ADDRESS=0xYOUR_ROLLUP_DEPLOYMENT +# Choose ONE input source. Same PostgreSQL schema as existing node. +DATABASE_URL=postgres://user:password@localhost/m3tering +# ACTIONS_FILE=actions.json +OUTPUT_DIR=prover-output-batch-0 +MAX_ACTIONS=10000 +MAX_SLOTS=4096 +# Explicit modeled schedule; does not activate a fork in the connected node. +GAS_SCHEDULE=current +EXECUTION_GAS_BUDGET=15000000 +TOTAL_GAS_BUDGET=15000000 +FIXED_EXECUTION_ALLOWANCE=1500000 +PER_WORD_OVERHEAD_ALLOWANCE=12000 +# Only required for the explicit run command. Never commit real credentials. PRIVATE_KEY= -BLOCK_INTERVAL= - -DATA_STRATEGY=delete - -STREAM_ID=0x567853282663b601bfdb9203819b1fbb3fe18926/m3tering/test -STREAMR_ENV=live +MAX_FEE_PER_GAS_WEI=30000000000 +MAX_PRIORITY_FEE_PER_GAS_WEI=1000000000 +ETH_RESERVE_WEI=1000000000000000 +SP1_PRIVATE_KEY= +SP1_ELF=elf/m3tering-program +MAX_PROVER_CYCLES=100000000 +MAX_PROVER_PGU=100000000 +# Required operator price cap, PROVE wei per PGU; must match auction tick size. +MAX_PRICE_PER_PGU= +SP1_CREDIT_RESERVE_WEI=0 +PROOF_TIMEOUT_SECONDS=3600 +CONFIRMATIONS=2 diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml deleted file mode 100644 index 06a17ae..0000000 --- a/.github/workflows/build-images.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Build and publish Docker images for Energy Tracker - -on: - push: - branches: [ "staging", "main" ] - pull_request: - branches: [ "staging", "main" ] - -env: - REGISTRY: ghcr.io - -permissions: - contents: read - packages: write - attestations: write - id-token: write - -jobs: - build-platform: - strategy: - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - arch: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - arch: arm64 - - runs-on: ${{ matrix.runner }} - - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Normalize image names - run: | - echo "PROVER_IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - echo "STREAMR_IMAGE_NAME=${GITHUB_REPOSITORY,,}/streamr-client" >> $GITHUB_ENV - - - name: Free disk space - uses: jlumbroso/free-disk-space@main - with: - tool-cache: true - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Prover image - push by digest with repository name - - name: Build Prover image (single platform) - id: build-prover - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - push: ${{ github.event_name != 'pull_request' }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ env.PROVER_IMAGE_NAME }},push-by-digest=true - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PROVER_IMAGE_NAME }}:buildcache-${{ matrix.arch }} - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PROVER_IMAGE_NAME }}:buildcache-${{ matrix.arch }},mode=max - - # Streamr client image - push by digest with repository name - - name: Build Streamr client image (single platform) - id: build-streamr - uses: docker/build-push-action@v6 - with: - context: ./streamr-client - platforms: ${{ matrix.platform }} - push: ${{ github.event_name != 'pull_request' }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ env.STREAMR_IMAGE_NAME }},push-by-digest=true - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.STREAMR_IMAGE_NAME }}:buildcache-${{ matrix.arch }} - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.STREAMR_IMAGE_NAME }}:buildcache-${{ matrix.arch }},mode=max - - - name: Save digests to files - if: github.event_name != 'pull_request' - run: | - echo ${{ steps.build-prover.outputs.digest }} > digests-${{ matrix.arch }}-prover-digest.txt - echo ${{ steps.build-streamr.outputs.digest }} > digests-${{ matrix.arch }}-streamr-digest.txt - - - name: Upload digests artifact - if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v4 - with: - name: digests-${{ matrix.arch }} - path: | - digests-${{ matrix.arch }}-prover-digest.txt - digests-${{ matrix.arch }}-streamr-digest.txt - - create-manifests: - needs: build-platform - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - - steps: - - name: Normalize image names - run: | - echo "PROVER_IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - echo "STREAMR_IMAGE_NAME=${GITHUB_REPOSITORY,,}/streamr-client" >> $GITHUB_ENV - - - name: Download all digests - uses: actions/download-artifact@v4 - with: - pattern: digests-* - merge-multiple: true - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Extract metadata for Prover image - id: meta-prover - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.PROVER_IMAGE_NAME }} - - - name: Create multi-arch manifest for Prover - run: | - AMD64_DIGEST=$(cat digests-amd64-prover-digest.txt) - ARM64_DIGEST=$(cat digests-arm64-prover-digest.txt) - echo AMD64_DIGEST=$AMD64_DIGEST - echo ARM64_DIGEST=$ARM64_DIGEST - TAGS=(${{ steps.meta-prover.outputs.tags }}) - TAG_ARGS="" - for tag in "${TAGS[@]}"; do - TAG_ARGS="$TAG_ARGS --tag $tag" - done - docker buildx imagetools create $TAG_ARGS \ - ${{ env.REGISTRY }}/${{ env.PROVER_IMAGE_NAME }}@$AMD64_DIGEST \ - ${{ env.REGISTRY }}/${{ env.PROVER_IMAGE_NAME }}@$ARM64_DIGEST - - - name: Extract metadata for Streamr client image - id: meta-streamr - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.STREAMR_IMAGE_NAME }} - - - name: Create multi-arch manifest for Streamr client - run: | - AMD64_DIGEST=$(cat digests-amd64-streamr-digest.txt) - ARM64_DIGEST=$(cat digests-arm64-streamr-digest.txt) - echo AMD64_DIGEST=$AMD64_DIGEST - echo ARM64_DIGEST=$ARM64_DIGEST - TAGS=(${{ steps.meta-streamr.outputs.tags }}) - TAG_ARGS="" - for tag in "${TAGS[@]}"; do - TAG_ARGS="$TAG_ARGS --tag $tag" - done - docker buildx imagetools create $TAG_ARGS \ - ${{ env.REGISTRY }}/${{ env.STREAMR_IMAGE_NAME }}@$AMD64_DIGEST \ - ${{ env.REGISTRY }}/${{ env.STREAMR_IMAGE_NAME }}@$ARM64_DIGEST diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3240afc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Protocol +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +jobs: + protocol: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler pkg-config libssl-dev + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo test --locked -p m3tering-protocol -p m3tering-prover + - run: cargo build --locked -p m3tering-prover --features proving + - name: Install pinned SP1 + run: | + curl -L https://sp1up.succinct.xyz | bash + "$HOME/.sp1/bin/sp1up" --version v6.3.1 + echo "$HOME/.sp1/bin" >> "$GITHUB_PATH" + - name: Build guest + run: cargo prove build --locked -p m3tering-program --output-directory elf + - name: Execute authenticated fixture without buying a proof + env: + SP1_ELF: elf/m3tering-program + WITNESS_FILE: fixtures/input.json + run: cargo run --locked -p m3tering-prover --features proving -- execute-fixture diff --git a/.gitignore b/.gitignore index 71a2b14..e80e107 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,7 @@ -# Cargo build -**/target - -# Cargo config -.cargo - -# Profile-guided optimization -/tmp -pgo-data.profdata - -# MacOS nuisances -.DS_Store - -# Proofs -**/proof-with-pis.json -**/proof-with-io.json - -# Env +/target/ +/elf/ +/prover-output*/ +.env +**/node_modules/ **/.env -.env.prebuilt - -# DIESEL files -schema.rs - -db/password.txt - -# DAppNodeSDK release directories - build_* - releases.json - docker-compose-tmp.yml - - **/node_modules - +*.log diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index a35f9f7..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "rust-analyzer.linkedProjects": [ - "program/Cargo.toml", - "node/Cargo.toml" - ], - "rust-analyzer.check.overrideCommand": [ - "cargo", - "clippy", - "--workspace", - "--message-format=json", - "--all-features", - "--all-targets", - "--", - "-A", - "incomplete-features" - ], - "rust-analyzer.runnables.extraEnv": { - "RUST_LOG": "debug", - "RUSTFLAGS": "-Ctarget-cpu=native" - }, - "rust-analyzer.runnables.extraArgs": [ - "--release", - "+nightly" - ], - "rust-analyzer.diagnostics.disabled": [ - "unresolved-proc-macro" - ], - "editor.rulers": [ - 100 - ], - "editor.inlineSuggest.enabled": true, - "[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer", - "editor.formatOnSave": true, - "editor.hover.enabled": "on" - }, - "cSpell.words": [ - "buildcache", - "Buildx", - "imagetools", - "jlumbroso" - ], -} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 50a1fc4..2b17a61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,7 +101,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-serde", - "alloy-trie 0.9.0", + "alloy-trie", "alloy-tx-macros", "auto_impl", "c-kzg", @@ -248,7 +248,7 @@ dependencies = [ "alloy-eips", "alloy-primitives", "alloy-serde", - "alloy-trie 0.9.0", + "alloy-trie", "serde", ] @@ -784,22 +784,6 @@ dependencies = [ "ws_stream_wasm", ] -[[package]] -name = "alloy-trie" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "983d99aa81f586cef9dae38443245e585840fcf0fc58b09aee0b1f27aed1d500" -dependencies = [ - "alloy-primitives", - "alloy-rlp", - "arrayvec", - "derive_more 2.0.1", - "nybbles 0.3.4", - "serde", - "smallvec", - "tracing", -] - [[package]] name = "alloy-trie" version = "0.9.0" @@ -810,7 +794,7 @@ dependencies = [ "alloy-rlp", "arrayvec", "derive_more 2.0.1", - "nybbles 0.4.0", + "nybbles", "serde", "smallvec", "tracing", @@ -1514,42 +1498,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower 0.5.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" -dependencies = [ - "axum-core 0.5.2", + "axum-core", "bytes", - "form_urlencoded", "futures-util", "http 1.3.1", "http-body 1.0.1", @@ -1557,7 +1507,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit 0.8.4", + "matchit", "memchr", "mime", "percent-encoding", @@ -1596,26 +1546,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-core" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" -dependencies = [ - "bytes", - "futures-core", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "backoff" version = "0.4.0" @@ -2106,6 +2036,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2516,43 +2452,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "diesel" -version = "2.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229850a212cd9b84d4f0290ad9d294afc0ae70fccaa8949dbe8b43ffafa1e20c" -dependencies = [ - "bitflags", - "byteorder", - "diesel_derives", - "itoa", - "pq-sys", - "r2d2", - "serde_json", -] - -[[package]] -name = "diesel_derives" -version = "2.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b96984c469425cb577bf6f17121ecb3e4fe1e81de5d8f780dd372802858d756" -dependencies = [ - "diesel_table_macro_syntax", - "dsl_auto_type", - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "diesel_table_macro_syntax" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" -dependencies = [ - "syn 2.0.104", -] - [[package]] name = "digest" version = "0.9.0" @@ -2612,32 +2511,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dsl_auto_type" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" -dependencies = [ - "darling", - "either", - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "dunce" version = "1.0.5" @@ -2771,45 +2650,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] -name = "energy-tracker-lib" -version = "0.1.0" -dependencies = [ - "alloy-consensus", - "alloy-primitives", - "alloy-rlp", - "alloy-sol-types", - "alloy-trie 0.8.1", - "ed25519-dalek", - "hex", - "rayon", - "serde", - "serde_json", -] - -[[package]] -name = "energy-tracker-program" -version = "0.1.0" -dependencies = [ - "alloy-sol-types", - "energy-tracker-lib", - "hex", - "rayon", - "serde", - "sp1-zkvm", -] - -[[package]] -name = "energy-tracker-verifier" -version = "0.1.0" +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ - "alloy", - "alloy-contract", - "alloy-rlp", - "alloy-sol-types", - "eyre", - "hex", - "serde_json", - "tokio", + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -2870,6 +2721,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fastrand" version = "2.3.0" @@ -2996,6 +2853,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3451,10 +3318,12 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3746,11 +3615,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -3828,11 +3698,10 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.4" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags", "libc", ] @@ -3900,6 +3769,44 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "m3tering-program" +version = "0.1.0" +dependencies = [ + "m3tering-protocol", + "sp1-zkvm", +] + +[[package]] +name = "m3tering-protocol" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "ed25519-dalek", + "hex", + "serde", + "serde_json", +] + +[[package]] +name = "m3tering-prover" +version = "0.1.0" +dependencies = [ + "alloy", + "alloy-primitives", + "alloy-sol-types", + "anyhow", + "fs2", + "hex", + "m3tering-protocol", + "reqwest", + "serde", + "serde_json", + "sp1-sdk", + "tokio", + "tokio-postgres", +] + [[package]] name = "mach2" version = "0.4.3" @@ -3935,12 +3842,6 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "md-5" version = "0.10.6" @@ -4024,6 +3925,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "native-tls" version = "0.2.14" @@ -4041,25 +3948,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "node" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "axum 0.8.4", - "diesel", - "dotenvy", - "energy-tracker-lib", - "energy-tracker-verifier", - "eyre", - "serde", - "serde_json", - "sp1-build", - "sp1-sdk", - "tokio", -] - [[package]] name = "nom" version = "7.1.3" @@ -4255,28 +4143,33 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "nybbles" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983bb634df7248924ee0c4c3a749609b5abcb082c28fffe3254b3eb3602b307" +checksum = "11d51b0175c49668a033fe7cc69080110d9833b291566cdf332905f3ad9c68a0" dependencies = [ "alloy-rlp", - "const-hex", "proptest", + "ruint", "serde", "smallvec", ] [[package]] -name = "nybbles" -version = "0.4.0" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d51b0175c49668a033fe7cc69080110d9833b291566cdf332905f3ad9c68a0" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "alloy-rlp", - "proptest", - "ruint", - "serde", - "smallvec", + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", ] [[package]] @@ -4886,6 +4779,25 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -4940,6 +4852,35 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.9.1", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -4964,16 +4905,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "pq-sys" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfd6cf44cca8f9624bc19df234fc4112873432f5fda1caff174527846d026fa9" -dependencies = [ - "libc", - "vcpkg", -] - [[package]] name = "prettyplease" version = "0.2.35" @@ -5144,7 +5075,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2", + "socket2 0.5.10", "thiserror 2.0.12", "tokio", "tracing", @@ -5181,7 +5112,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -5201,17 +5132,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - [[package]] name = "radium" version = "0.7.0" @@ -5434,8 +5354,10 @@ checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http 1.3.1", "http-body 1.0.1", "http-body-util", @@ -5445,6 +5367,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -5776,15 +5699,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] - [[package]] name = "schemars" version = "0.9.0" @@ -6129,6 +6043,18 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.10" @@ -6598,6 +6524,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "sp1-build" version = "6.3.1" @@ -7254,6 +7190,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -7371,6 +7318,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn-solidity" version = "1.2.1" @@ -7418,6 +7376,27 @@ dependencies = [ "windows", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -7579,7 +7558,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.10", "tokio-macros", "windows-sys 0.52.0", ] @@ -7605,6 +7584,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-postgres" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.1", + "socket2 0.6.5", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -7692,7 +7697,7 @@ checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ "async-stream", "async-trait", - "axum 0.7.9", + "axum", "base64 0.22.1", "bytes", "h2", @@ -7707,7 +7712,7 @@ dependencies = [ "prost", "rustls-native-certs", "rustls-pemfile", - "socket2", + "socket2 0.5.10", "tokio", "tokio-rustls", "tokio-stream", @@ -7939,7 +7944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27dfcc06b8d9262bc2d4b8d1847c56af9971a52dd8a0076876de9db763227d0d" dependencies = [ "async-trait", - "axum 0.7.9", + "axum", "futures", "http 1.3.1", "http-body-util", @@ -8005,12 +8010,33 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-width" version = "0.1.14" @@ -8151,49 +8177,43 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasite" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.104", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ - "cfg-if", "js-sys", - "once_cell", + "tokio", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8201,22 +8221,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.104", - "wasm-bindgen-backend", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -8250,9 +8270,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -8298,6 +8318,19 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.0" @@ -8386,6 +8419,17 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index 6694298..8d2f6f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -members = ["lib", "program", "contracts", "node"] -default-members = ["node"] +members = ["lib", "program", "host"] +default-members = ["host"] resolver = "2" [profile.release] @@ -12,7 +12,6 @@ strip = true [workspace.dependencies] tokio = { version = "1", features = ["full"] } alloy-sol-types = "1.0.0" -alloy-rlp = "0.3.12" alloy-primitives = { version = "1.2.1", features = ["serde"] } serde = { version = "1.0.200", default-features = false, features = ["derive"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 92342e1..0000000 --- a/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM rust:1.97.1-bookworm AS builder - -RUN apt-get update && apt-get install -y \ - clang mold \ - libpq-dev libssl-dev pkg-config libssl3 libpq5 curl \ - protobuf-compiler \ - && rm -rf /var/lib/apt/lists/* - -ENV PATH="/root/.sp1/bin:${PATH}" - -WORKDIR /app - -RUN curl -L https://sp1up.succinct.xyz | bash && sp1up - -COPY . . -RUN cargo clean && cargo build --release - -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y libpq-dev libpq5 ca-certificates && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /app/target/release/node /usr/local/bin/prover-node - -CMD ["prover-node"] \ No newline at end of file diff --git a/README.md b/README.md index 96569fd..e920938 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,128 @@ -# M3terChain Validity Rollup Prover +# M3tering Prover πŸ»β€β„οΈ -This repository implements an [SP1](https://github.com/succinctlabs/sp1) based prover client for generating SNARK proofs of offchain energy metering, consistent with the m3tering protocol. +The prover validates signed meter actions, aggregates energy and produces SP1 proofs for sparse [Rollup](https://github.com/M3tering/Rollup) state updates. The host reads only selected storage words and signing keys, plans a batch that fits its gas budget, proves the transition, and submits the resulting absolute replacements. -## Requirements +## Repository layout -- [Rust](https://rustup.rs/) -- [SP1](https://docs.succinct.xyz/docs/sp1/getting-started/install) -- [PostgreSQL](https://www.postgresql.org/download/) -- [Diesel CLI](https://diesel.rs/guides/getting-started/) -- [Docker](https://www.docker.com/get-started/) +- `lib`: signed-action accounting, packed state and the shared statement/wire codec. +- `program`: SP1 guest that executes the transition and commits its 32-byte public value. +- `host`: RPC/database/file inputs, gas/funding admission, local execution, network proving and transaction submission. +- `fixtures`: deterministic signed-action input and matching contract encoding. +- `streamr-client`: gossip ingestion and PostgreSQL schema for the host's queue. -## Quick Setup +The workspace packages are `m3tering-protocol`, `m3tering-program` and `m3tering-prover`. Protocol documentation lives in this README. -The setup uses prebuilt docker images of the `prover` and the `prover/streamr-client`. +## Protocol -- Clone the repository +Four meter records share one uint256 word. Meter `id` uses slot `id/4` and lane `id%4`. Each lane contains a uint40 Wh account in its low bits and a uint24 nonce in its high bits. The slot range is uint32, covering meter IDs 0–17,179,869,183. -```sh -git clone https://github.com/M3tering/Prover.git -``` - -```sh -cd Prover -``` +An action signs exactly eight bytes: uint32 big-endian nonce followed by uint32 big-endian **mWh**. Ed25519 signatures are verified over these exact bytes. Consumed nonces are skipped; a gap or invalid signature stops that meter's accepted prefix. Malformed encodings and field overflows reject the witness. -- Set up environment variables +The guest aggregates accepted mWh per meter and adds `floor(sum/1000)` Wh once per commit. Fractional Wh are discarded between commits. Zero-Wh increments still advance the nonce. Account and nonce limits are 1,099,511,627,775 Wh and 16,777,215. Untouched lanes are preserved, and all selected meters sharing a word produce one update for that word. -```sh -cp .env.example .env -``` +Every public key stays fully readable in Rollup storage. Only the current `M3ter.ownerOf(id)` can change it. Zero revokes. Keys are authenticated at commit time; rotating a used key invalidates a pending proof. The prover performs no MPT verification or block-header decoding. -> Make sure you set all the variables in the `.env` file +### Wire format -- Start the docker containers +`commitState(bytes updates,bytes proof)` receives strictly ascending **36-byte records**: -```sh -docker compose up --build -d +``` +uint32 slot || uint256 newWord ``` -- See Logs +Both integers are big endian. Every changed slot has its own four-byte ID and absolute replacement. There are no deltas, modes, block anchors or global sequence parameters. -```sh -docker compose logs -``` +The guest's public output is `keccak256(transcript)` (32 bytes). The transcript contains chain ID (32 bytes), Rollup address (20), then slot (4), old word (32), new word (32), used-key hash (32) for each update. The prefix is 52 bytes and each record is 100 bytes. -## Manual Setup +The used-key hash covers four 32-byte entries: the full key for a changed lane and zero for an unchanged lane. The contract reconstructs this transcript from its live old words/keys and the proposed replacements. Old words and keys do not appear in commit calldata. Destination fields prevent proof reuse on another chain/deployment; old words reject stale updates; indices, new words and keys authenticate the exact transition. Count is implicit and the configured SP1 key identifies the program. -1. **Clone the repository:** - ```sh - git clone https://github.com/m3tering/prover.git - cd prover - ``` +The host pins its RPC reads to one canonical block hash for consistency, but that block is not committed and imposes no expiry. Proofs for disjoint words can coexist. Proofs sharing a word conflict, even when they change different meters in that word. A proof cannot replay after its own successful transition because old words and nonces have changed. -2. **Install dependencies:** - - Install Rust and SP1 as described above. - - Install Diesel CLI: - ```sh - cargo install diesel_cli --no-default-features --features postgres - ``` +The contract writes in one loop before a static verifier call. Verification failure rolls back all writes and length changes. After success, `StateCommitted(bytes updates)` emits the exact payload for indexers. -3. **Configure environment variables:** - - Copy the example environment file and edit as needed: - ```sh - cp .env.example .env - ``` - - Set your database URL and any required RPC URLs in `.env`. +## Build and test -4. **Setup the database:** - - Create the database: - ```sh - createdb m3tering-db - ``` - - Run Diesel migrations: - ```sh - diesel migration run - ``` +Use Rust stable and SP1 **6.3.1**. The proving-enabled host needs protobuf/protoc, pkg-config and OpenSSL development headers. Install SP1 from the official Succinct distribution, pinning `sp1up --version v6.3.1`. -## Running the Program +```sh +cargo fmt --all -- --check +cargo test --locked -p m3tering-protocol -p m3tering-prover +cargo build --locked -p m3tering-prover --features proving +cargo prove build --locked -p m3tering-program --output-directory elf +SP1_ELF=elf/m3tering-program WITNESS_FILE=fixtures/input.json \ + cargo run --locked -p m3tering-prover --features proving -- execute-fixture +``` -### Build and Run the Node +`execute-fixture` executes the actual guest locally, verifies that its public output matches the shared transition code and prints the program verification key. It neither buys a proof nor submits an Ethereum transaction. Re-derive the key after changing the guest, dependencies or toolchain. Set that key and the target-chain verifier/M3ter addresses in the Rollup constants before deployment. -The main backend service is in the `node` package. To start the server: +To regenerate the cross-language fixtures: ```sh -cargo run --release +FIXTURE_DIR="$PWD/fixtures" cargo test --locked -p m3tering-protocol --test protocol end_to_end_and_export ``` -### Expose Local Server with ngrok +Copy `fixtures/encoding.json` to the Rollup repository's `test/fixtures/statement.json` when releasing a coordinated codec change. Unit/integration tests cover signatures, mWh/Wh conversion, truncation, capacities, shared words, statement bindings and four-byte slots. Budget tests check the largest admitted homogeneous batch and rejection of one additional slot. CI also builds and executes the guest. + +## Inputs and operation + +Copy `.env.example` values into your process environment. The Rust host does not automatically load dotenv files. Set `RPC_URL`, `ROLLUP_ADDRESS`, a unique `OUTPUT_DIR`, and exactly one input source: -If you want to expose your local server to the internet (for testing webhooks, integrations, etc.), you can use ngrok: -Install ngrok (if not already installed) +- `ACTIONS_FILE`: JSON object keyed by decimal meter IDs, with arrays of `{ "message": "8-byte hex", "signature": "64-byte hex" }`. +- `DATABASE_URL`: PostgreSQL queue using `streamr-client/db/migrations/initial_00.sql`. -Expose your local server (port 8080): +Actions must be ordered by nonce per meter. Accounting always parses nonce/energy from the signed bytes, never trusts separate database metadata for those values. The host reads `wordCount()` before `words(slot)`; unallocated slots are zero because the generated array getter reverts beyond its length. ```sh -ngrok http 8080 -``` +# Read state, select a fitting batch and save input.json/transition.json: +cargo run --locked -p m3tering-prover -- plan -Copy the forwarding URL from the ngrok output and use it to access your local server from anywhere. +# Buy a network proof and submit only after admission checks pass: +cargo run --locked -p m3tering-prover --features proving -- run +``` -Note: You may need to sign up for an ngrok account and authenticate your agent for extended usage. +Each invocation handles one batch. Schedule subsequent invocations with unique output directories. Keep one submitter per wallet/deployment unless you provide distributed coordination. A local file lock coordinates this host on one machine only. -### Proving Thread +`run` checks the deployed program key against the local ELF before buying a proof. It locally executes the guest, validates the expected public output, bounds cycles/PGU, checks SP1 credits, and applies an explicit network price cap and timeout. Ethereum `PRIVATE_KEY` and `SP1_PRIVATE_KEY` are separate credentials. Never commit real credentials. -The proving process runs in a **dedicated background thread** using Tokio. -This thread periodically (interval configurable via the `BLOCK_INTERVAL` environment variable) queries the database for unverified payloads, groups them, and runs the prover logic. -Once a proof is generated, the thread commits the state and updates the relevant payloads as verified in the database. +Before submitting, the host verifies the returned proof, checks current words/used keys again, simulates the call, estimates gas, adds 20% headroom and checks the signer's pending ETH balance using maximum fees. Only a successful, sufficiently confirmed, still-canonical receipt allows selected consumed database messages to be marked verified. -**Key points:** -- The proving thread does not block the main server endpoints. -- The interval for proving is set via the `.env` file (`BLOCK_INTERVAL`). -- All database and proving operations are handled asynchronously. +Proofs and transaction receipts are saved. Output directories containing `proof.bin` or `transaction.json` cannot be overwritten. Preserve a paid proof after a send failure; it can be reused only while its old words and used keys remain current. There is no automatic paid retry, queue quarantine, distributed reservation service or submit-only recovery daemon. Malformed entries, nonce gaps and overflow can require operator attention. -The server will start on `http://localhost:8080` and expose several endpoints: -- `/payload` β€” Submit a single energy payload (POST) -- `/batch-payloads` β€” Submit multiple payloads (POST) -- `/run_prover` β€” Generate a proof (GET) -- `/vkey` β€” Retrieve the verification key (GET) -- `/health` β€” Health check (GET) +### Streamr ingestion -### Example: Submit a Payload +The ingestion client subscribes to `STREAM_ID` and saves action bytes/signatures in PostgreSQL. It can run independently of proving: ```sh -curl -X POST http://localhost:8080/payload \ - -H "Content-Type: application/json" \ - -d '{"m3ter_id":12345,"message":"payload_data"}' +cd streamr-client +npm ci +# Set DATABASE_URL and STREAM_ID in the environment or .env. +# STREAMR_CLIENT_PORT defaults to 3000. +npm start ``` -### Example: Generate a Proof +Startup applies the checked-in database migration. `/health` serves the process health endpoint. Queue ingestion does not authenticate an energy transition; proof generation and on-chain verification do that. + +## Gas admission and capacity + +`MAX_SLOTS=4096` is a selection ceiling, not a promise that 4,096 updates fit in one transaction. The host stops selecting slots when its **mixed** fresh/reused cost estimate exceeds either execution or total gas budget. Selection happens before local proving and paid network requests. It also bounds action count, cycles and PGU. + +For K reused words and Z fresh words, with F fixed overhead and H per-word overhead: -```sh -curl "http://localhost:8080/run_prover?proof_type=groth16" ``` +current gas = F + H*(K+Z) + 5,000*K + 22,100*Z +forecast execution = F + H*(K+Z) + 12,200*(K+Z) +forecast total = forecast execution + 97,920*Z +``` + +Defaults: F=1.5m, H=12,000, both budgets=15m. Every result receives 20% headroom, rounded upward. Current total gas is clamped to the transaction/execution cap; budgets also respect the observed block limit. The allowance covers four cold key reads per changed word, loop/transcript work, calldata and event costs. The fixed allowance reserves verifier/proof/array-length overhead and requires deployment-specific calibration. -## Troubleshooting +| Schedule | Default all-reused maximum | Default all-fresh maximum | +|---|---:|---:| +| `current` | 647 | 322 | +| `glamsterdam-model` | 454 | 90 | -- Ensure your `.env` file is correctly configured and loaded. -- Make sure PostgreSQL is running and accessible. -- If you encounter build errors, update your toolchains and dependencies. +Four-byte IDs add one payload byte per slot. At 512 slots, local receipts increased by 6,682 gas over three-byte IDs; the existing conservative overhead still covers it, so admission defaults are unchanged. Actual mock-verifier ceilings at 16,777,216 gas were 1,117 reused / 521 fresh words for ordinary fixture values, or 1,100 / 517 with all-nonzero new words. These exclude real SP1 verification and are **not** production limits. The Rollup README and benchmark JSON contain the receipt measurements. -## License +The Glamsterdam model uses the stated [EIP-8037](https://eips.ethereum.org/EIPS/eip-8037) and [EIP-8038](https://eips.ethereum.org/EIPS/eip-8038) prices; selecting it does not activate a fork. Choose the schedule and allowances appropriate to the target chain. Higher total state-gas budgets can admit more fresh words only where chain rules permit; execution limits still apply. -[MIT](LICENSE) +A valid proof does not guarantee sufficient gas or inclusion. Reorgs, rotations, concurrent commits, fee changes and other wallet spending can race preflight checks. Oversized local execution is rejected before buying a proof; lower caps and replan. Deployment validation must include a real proof against the intended verifier and gas calibration on the actual target client. diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml deleted file mode 100644 index fd271f8..0000000 --- a/contracts/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "energy-tracker-verifier" -version = "0.1.0" -edition = "2021" - -[dependencies] -alloy = { version = "1.0.13", features = ["full"] } -alloy-rlp = { workspace = true } -hex = { workspace = true} -eyre = "0.6" -alloy-contract = "1.0.20" -alloy-sol-types = { workspace = true } -tokio = { workspace = true } -serde_json = { workspace = true } diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs deleted file mode 100644 index 8b88ed4..0000000 --- a/contracts/src/lib.rs +++ /dev/null @@ -1,292 +0,0 @@ -use std::collections::HashMap; - -use alloy::{ - dyn_abi::DynSolValue, - hex, - json_abi::JsonAbi, - primitives::{Address, Bytes, B256, U256}, - providers::{ProviderBuilder}, - signers::local::PrivateKeySigner, -}; - -pub use alloy::providers::Provider; - -use alloy_contract::Interface; -use alloy_rlp::{encode, RlpEncodable}; -use eyre::{Ok, Result}; - -#[derive(Debug, RlpEncodable)] -pub struct Account { - nonce: u64, - balance: U256, - storage_hash: B256, - code_hash: B256, -} - -fn get_rollup_address() -> Address { - "0xf8f2d4315DB5db38f3e5c45D0bCd59959c603d9b" - .parse() - .expect("Invalid address") -} - -pub fn get_m3ter_address() -> Address { - "0x9C547B649475f1bE81323AefdbcF209C17961D5E" - .parse() - .expect("Invalid address") -} - -fn get_rollup_abi() -> JsonAbi { - let call_abi = r#"[ - { - "inputs":[], - "name":"L1Checkpoint", - "outputs":[ - { - "internalType":"bytes32", - "name":"", - "type":"bytes32" - } - ], - "stateMutability":"view", - "type":"function" - }, - { - "name": "latestStateAddress", - "type": "function", - "inputs": [ - { - "name": "io", - "type": "uint256" - } - ], - "outputs": [ - { - "type": "address" - } - ], - "stateMutability": "view" - }, - { - "inputs": [], - "name": "anchorBlock", - "outputs": [ - { - "internalType" :"bytes32", - "name":"", - "type": - "bytes32" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes", - "name": "accountBlob", - "type": "bytes" - }, - { - "internalType": "bytes", - "name": "nonceBlob", - "type": "bytes" - }, - { - "internalType": "bytes", - "name": "proof", - "type": "bytes" - } - ], - "name": "commitState", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs":[], - "name":"SP1_PROGRAM_VKEY", - "outputs":[ - { - "internalType":"bytes32", - "name":"", - "type":"bytes32" - } - ], - "stateMutability":"view", - "type":"function" - } - ]"#; - - serde_json::from_str(call_abi).expect("Failed to parse ABI") -} - -pub async fn get_provider() -> Result { - let rpc_url = std::env::var("RPC_URL").unwrap_or_else(|_| { - "https://eth-sepolia.g.alchemy.com/v2/URjQnzNCUHumxPFL8VDoFBmpX4uqL6X8".to_string() - }); - println!("Connecting to provider at: {}", rpc_url); - let private_key = std::env::var("PRIVATE_KEY").expect("private key should exist in env"); - let private_key = if private_key.starts_with("0x") { - private_key.strip_prefix("0x").unwrap() - } else { - private_key.as_str() - }; - let signer = PrivateKeySigner::from_slice( - &hex::decode(private_key).expect("Failed to decode private key"), - ) - .expect("Failed to create signer from private key"); - - let provider = ProviderBuilder::new() - .wallet(signer) - .with_cached_nonce_management() - .connect_http(rpc_url.parse()?); - Ok(Box::new(provider)) -} - -pub async fn get_anchor_block_hash(provider: &impl Provider) -> Result { - let rollup_address = get_rollup_address(); - let abi: JsonAbi = get_rollup_abi(); - let interface = Interface::new(abi); - let contract = interface.connect(rollup_address, provider); - - let get_anchor_block = contract.function("anchorBlock", &[])?; - let result = get_anchor_block.call().await?; - - let anchor_hash = B256::from_slice(result[0].as_fixed_bytes().unwrap().0); - Ok(anchor_hash) -} - -pub async fn get_storage_proofs( - provider: &impl Provider, - slots: Vec, -) -> Result<( - Vec, - Vec, - B256, - HashMap)>, - B256, -)> { - let anchor_block = get_anchor_block_hash(provider).await?; - - println!("slots {:?}", slots); - let proof = provider.get_proof(get_m3ter_address(), slots); - - println!("geting storage_proofs at block = {:?}", anchor_block); - let proof_at_block = proof - .hash(anchor_block) - .await - .map_err(|e| eyre::eyre!("Failed to get proof: {}", e))?; - - println!("storage_proofs = {:?}", proof_at_block.storage_proof); - - let account = Account { - nonce: proof_at_block.nonce, - balance: proof_at_block.balance, - code_hash: proof_at_block.code_hash, - storage_hash: proof_at_block.storage_hash, - }; - - let encoded_account = encode(account); - let mut storage_proofs: HashMap)> = HashMap::new(); - for proof in proof_at_block.storage_proof.iter() { - storage_proofs - .entry(proof.key.as_b256()) - .insert_entry((proof.value, proof.proof.clone())); - } - - Ok(( - proof_at_block.account_proof, - encoded_account, - proof_at_block.storage_hash, - storage_proofs, - anchor_block, - )) -} - -pub async fn get_block_rpl_bytes(provider: &impl Provider, block_hash: B256) -> Result> { - let block = provider - .get_block_by_hash(block_hash) - .await - .map_err(|e| eyre::eyre!("Failed to get block: {}", e))?; - - if let Some(block) = block { - let block_header = block.header; - let block_bytes = encode(block_header.into_consensus()); - Ok(block_bytes) - } else { - Err(eyre::eyre!("Block not found")) - } -} - -pub async fn get_previous_values(provider: &impl Provider, selector: U256) -> Result { - let rollup_address = get_rollup_address(); - - let abi: JsonAbi = get_rollup_abi(); - let interface = Interface::new(abi); - - let contract = interface.connect(rollup_address, &provider); - let call_builder = contract.function("latestStateAddress", &[selector.into()])?; - println!("getting state address"); - let state_address = call_builder.call().await?; - - println!("state address {:?}", state_address); - let code = provider - .get_code_at(state_address[0].as_address().unwrap()) - .await?; - println!("code length: {}", code.len()); - Ok(code) -} - -pub async fn commit_state( - provider: &impl Provider, - account_blob: &Bytes, - nonce_blob: &Bytes, - proof: &Bytes, -) -> Result { - let rollup_address = get_rollup_address(); - let abi: JsonAbi = get_rollup_abi(); - let interface = Interface::new(abi); - let contract = interface.connect(rollup_address, provider); - - let call_builder = contract.function( - "commitState", - &[ - DynSolValue::Bytes(account_blob.to_vec()), - DynSolValue::Bytes(nonce_blob.to_vec()), - DynSolValue::Bytes(proof.to_vec()), - ], - )?; - - let pending_tx = call_builder.send().await?; - - // Send the transaction - // let pending_tx = provider.send_raw_transaction(&signed_tx.as_bytes()).await?; - println!("Transaction sent with hash: {:?}", &pending_tx.tx_hash()); - let hash = *pending_tx.tx_hash(); - // Wait for confirmation - let receipt = pending_tx.get_receipt().await?; - println!("Transaction confirmed in block: {:?}", receipt.block_number); - Ok(hash) -} - -pub async fn check_program_vkey(provider: &impl Provider, vkey_hash: [u8; 32]) -> Result { - let rollup_address = get_rollup_address(); - let abi: JsonAbi = get_rollup_abi(); - let interface = Interface::new(abi); - let contract = interface.connect(rollup_address, provider); - - let call_builder = contract.function("SP1_PROGRAM_VKEY", &[])?; - - let result = call_builder.call().await?; - - let vkey = result[0].as_fixed_bytes().unwrap(); - println!("on-chain vkey: {:?}, current vkey {:?}", vkey.0, vkey_hash); - Ok(vkey.0 == vkey_hash.as_slice()) -} - -pub async fn check_gas_balance(provider: &impl Provider) -> Result { - let address = "0x8ac5e3097a39e5e1412af04673085fc1026c0b57".parse::
()?; - let balance = provider.get_balance(address).await?; - Ok(balance > U256::from(10000000000u64)) -} diff --git a/diesel.toml b/diesel.toml deleted file mode 100644 index a0d61bf..0000000 --- a/diesel.toml +++ /dev/null @@ -1,9 +0,0 @@ -# For documentation on how to configure this file, -# see https://diesel.rs/guides/configuring-diesel-cli - -[print_schema] -file = "src/schema.rs" -custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] - -[migrations_directory] -dir = "migrations" diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 8f9c843..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,53 +0,0 @@ -services: - db: - image: postgres - restart: always - # set shared memory limit when using docker compose - shm_size: 128mb - environment: - - POSTGRES_DB=m3tering-db - - POSTGRES_USER=${DB_USER:-postgres} - - POSTGRES_PASSWORD=${DB_PASSWORD:-m3tering} - expose: - - 5432 - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] - interval: 10s - timeout: 5s - retries: 5 - - streamr-client: - image: ghcr.io/m3tering/prover/streamr-client:main - restart: unless-stopped - ports: - - "${STREAMR_CLIENT_PORT:-3000}:${STREAMR_CLIENT_PORT:-3000}" - environment: - - DATABASE_URL=${DATABASE_URL:-postgres://postgres:m3tering@db:5432/m3tering-db} - - STREAM_ID=${STREAM_ID} - - PRIVATE_KEY=${PRIVATE_KEY} - - STREAMR_ENV=${STREAMR_ENV:-live} - - STREAMR_CLIENT_PORT=${STREAMR_CLIENT_PORT:-3000} - depends_on: - db: - condition: service_healthy - - energy-tracker: - image: ghcr.io/m3tering/prover:main - restart: unless-stopped - ports: - - "${PROVER_NODE_PORT:-8080}:${PROVER_NODE_PORT:-8080}" - environment: - - DATABASE_URL=${DATABASE_URL:-postgres://postgres:m3tering@db:5432/m3tering-db} - - NETWORK_RPC_URL=${NETWORK_RPC_URL:-https://rpc.mainnet.succinct.xyz} - - RPC_URL=${RPC_URL:-https://eth-sepolia.g.alchemy.com/v2/URjQnzNCUHumxPFL8VDoFBmpX4uqL6X8} - - PRIVATE_KEY=${PRIVATE_KEY} - - BLOCK_INTERVAL=${BLOCK_INTERVAL} - - DATA_STRATEGY=${DATA_STRATEGY:-delete} - - PROVER_NODE_PORT=${PROVER_NODE_PORT:-8080} - - QUERY_LIMIT=${QUERY_LIMIT} - depends_on: - db: - condition: service_healthy - -volumes: - db-data: diff --git a/fixtures/encoding.json b/fixtures/encoding.json new file mode 100644 index 0000000..daa9c61 --- /dev/null +++ b/fixtures/encoding.json @@ -0,0 +1,6 @@ +{ + "commitment": "0x772efb6498d80b6dc9137e37d4c4224a991505de619a6d07165da51b26a6e913", + "keyHash": "0x2c9d00d7c02a560bb8ec98516bcec77cdae44c2ddbf1898eaaa716d327a06230", + "oldWord": "0x000000000000000000000000000000000000000000000000000001000000000a", + "payload": "0x000000000000010000000063000000000000000000000000000000000000030000000029000000010000000000000000000000000000000000000000000000000000010000000000000003e80000000000000000000000000000000000000000000000000000010000000007" +} \ No newline at end of file diff --git a/fixtures/input.json b/fixtures/input.json new file mode 100644 index 0000000..6a7c908 --- /dev/null +++ b/fixtures/input.json @@ -0,0 +1,59 @@ +{ + "chain_id": "0x1", + "rollup": "0x1111111111111111111111111111111111111111", + "words": { + "0": "0x1000000000a", + "1": "0x0", + "1000": "0x0" + }, + "keys": { + "0": [ + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61" + ], + "1": [ + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61" + ], + "1000": [ + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61", + "0x197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61" + ] + }, + "actions": { + "0": [ + { + "message": "0000000200006400", + "signature": "6afae38bd225b8c65cec0fc5c4dbe5e9ef41dd8e0a44e8cb97c4d22dfbe38411c7913d8de8e1b5f2151ecd2a034067aca9ad2d0f0d32a5fac25c23727856f108" + }, + { + "message": "00000003000015e0", + "signature": "c54751989ece7c30de0edcb642bb863aef71f10953ea5a7af4a4871944ed17087da0f473f95e5aa3c66be99d4df962976a5f5163cb716e616a2c8b76e9a7a102" + } + ], + "3": [ + { + "message": "000000010001869f", + "signature": "7bbacda2f4dc4adbdb5e400fee5675424af5359fe3b02887fdf7c4ca02647fbc5b74e2f6b3b7d1c5c03ebdd1ab3a41158f656efc2b94ebfba853e4f48cc6c401" + } + ], + "4": [ + { + "message": "0000000100000258", + "signature": "15b3392f51d234f2a1cd643678ed64a39ea7720b93b46598a7dfe7bf773800c990b705587dc338a9ec7074e180267eefcaf05d911875cfa3e17419d2b4628603" + } + ], + "4000": [ + { + "message": "0000000100001b58", + "signature": "3a09064bb641673a256f6f5205384341c95cb065d29d43ea77f8e171c1c3a3961656c57eef52a0c93b78365c96d3f92253306f8c16b5b22512c34e5d1a5d9c0c" + } + ] + } +} \ No newline at end of file diff --git a/fixtures/transition.json b/fixtures/transition.json new file mode 100644 index 0000000..ea1b890 --- /dev/null +++ b/fixtures/transition.json @@ -0,0 +1,133 @@ +{ + "public_values": "0x772efb6498d80b6dc9137e37d4c4224a991505de619a6d07165da51b26a6e913", + "updates": [ + { + "slot": 0, + "old": "0x1000000000a", + "new": "0x10000000063000000000000000000000000000000000000030000000029", + "keys_hash": "0x2c9d00d7c02a560bb8ec98516bcec77cdae44c2ddbf1898eaaa716d327a06230" + }, + { + "slot": 1, + "old": "0x0", + "new": "0x10000000000", + "keys_hash": "0x7c177a109df34e91a57e7bdd09b93561a9e892ef7eea8bbdf6bf4b11ce148604" + }, + { + "slot": 1000, + "old": "0x0", + "new": "0x10000000007", + "keys_hash": "0x7c177a109df34e91a57e7bdd09b93561a9e892ef7eea8bbdf6bf4b11ce148604" + } + ], + "payload": [ + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 99, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 41, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 232, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 7 + ] +} \ No newline at end of file diff --git a/host/Cargo.toml b/host/Cargo.toml new file mode 100644 index 0000000..05bb5b8 --- /dev/null +++ b/host/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "m3tering-prover" +version = "0.1.0" +edition = "2021" +[features] +default = [] +proving = ["dep:sp1-sdk"] +[dependencies] +m3tering-protocol = { path = "../lib" } +alloy = { version = "1.0.13", features = ["full"] } +alloy-sol-types = { workspace = true } +alloy-primitives = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +hex = { workspace = true } +reqwest = { version = "0.12", features = ["json"] } +anyhow = "1" +fs2 = "0.4" +tokio-postgres = "0.7" +sp1-sdk = { version = "=6.3.1", default-features = false, features = ["network"], optional = true } diff --git a/host/src/budget.rs b/host/src/budget.rs new file mode 100644 index 0000000..061a111 --- /dev/null +++ b/host/src/budget.rs @@ -0,0 +1,136 @@ +//! Conservative admission estimates, not a replacement for eth_estimateGas. +use alloy_primitives::U256; +use anyhow::{ensure, Result}; +#[derive(Debug, Clone, Copy)] +pub struct Budget { + pub execution_limit: u64, + pub total_limit: u64, + pub fixed_execution: u64, + pub per_word_overhead: u64, + pub post_repricing: bool, +} +impl Budget { + pub fn estimate(&self, old: &[U256]) -> Result<(u64, u64)> { + let fresh = old.iter().filter(|w| w.is_zero()).count() as u64; + let count = old.len() as u64; + let (per_existing, per_fresh, state) = if self.post_repricing { + (12_200, 12_200, 97_920) + } else { + (5_000, 22_100, 0) + }; + let execution = self + .fixed_execution + .checked_add( + count + .checked_mul(self.per_word_overhead) + .ok_or_else(|| anyhow::anyhow!("budget overflow"))?, + ) + .and_then(|x| x.checked_add((count - fresh) * per_existing + fresh * per_fresh)) + .ok_or_else(|| anyhow::anyhow!("budget overflow"))?; + let total = execution + .checked_add(fresh * state) + .ok_or_else(|| anyhow::anyhow!("budget overflow"))?; + Ok((execution, total)) + } + /// Binary search using the exact admission model including 20% headroom. + pub fn max_homogeneous(&self, fresh: bool, ceiling: usize) -> usize { + let words = vec![if fresh { U256::ZERO } else { U256::from(1) }; ceiling]; + let (mut low, mut high) = (0, ceiling); + while low < high { + let mid = low + (high - low + 1) / 2; + if self.fits(&words[..mid]) { + low = mid; + } else { + high = mid - 1; + } + } + low + } + pub fn fits(&self, old: &[U256]) -> bool { + self.estimate(old).is_ok_and(|(e, t)| { + e.checked_mul(6) + .and_then(|x| x.checked_add(4)) + .map(|x| x / 5) + .is_some_and(|x| x <= self.execution_limit) + && t.checked_mul(6) + .and_then(|x| x.checked_add(4)) + .map(|x| x / 5) + .is_some_and(|x| x <= self.total_limit) + }) + } +} +pub fn require_funding(balance: U256, gas: u64, max_fee: u128, reserve: U256) -> Result<()> { + let required = U256::from(gas) + .checked_mul(U256::from(max_fee)) + .and_then(|x| x.checked_add(reserve)) + .ok_or_else(|| anyhow::anyhow!("funding overflow"))?; + ensure!( + balance >= required, + "insufficient pending ETH balance for gas limit Γ— max fee + reserve" + ); + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fresh_and_existing_differ_in_state_dimension() { + let b = Budget { + execution_limit: 16_000_000, + total_limit: 100_000_000, + fixed_execution: 0, + per_word_overhead: 0, + post_repricing: true, + }; + assert_eq!( + b.estimate(&[U256::ZERO, U256::from(1)]).unwrap(), + (24_400, 122_320) + ); + } + #[test] + fn both_budgets_apply() { + let b = Budget { + execution_limit: 15_000, + total_limit: 100_000, + fixed_execution: 0, + per_word_overhead: 0, + post_repricing: true, + }; + assert!(b.fits(&[U256::from(1)])); + assert!(!b.fits(&[U256::ZERO])); + assert!(!b.fits(&[U256::from(1); 2])); + } + #[test] + fn maximum_is_admitted_but_next_word_is_not() { + for post in [false, true] { + for fresh in [false, true] { + let b = Budget { + execution_limit: 15_000_000, + total_limit: 15_000_000, + fixed_execution: 1_500_000, + per_word_overhead: 12_000, + post_repricing: post, + }; + let count = b.max_homogeneous(fresh, 4096); + let old = if fresh { U256::ZERO } else { U256::from(1) }; + assert!(b.fits(&vec![old; count])); + assert!(!b.fits(&vec![old; count + 1])); + assert_eq!( + count, + match (post, fresh) { + (false, false) => 647, + (false, true) => 322, + (true, false) => 454, + (true, true) => 90, + } + ); + } + } + } + #[test] + fn funding_uses_max_fee_not_current_base_fee() { + assert!(require_funding(U256::from(199), 10, 20, U256::ZERO).is_err()); + assert!(require_funding(U256::from(200), 10, 20, U256::ZERO).is_ok()); + assert!(require_funding(U256::from(200), 10, 20, U256::from(1)).is_err()); + } +} diff --git a/host/src/main.rs b/host/src/main.rs new file mode 100644 index 0000000..9ae9b22 --- /dev/null +++ b/host/src/main.rs @@ -0,0 +1,555 @@ +mod budget; +use alloy::{ + providers::{Provider, ProviderBuilder}, + rpc::types::TransactionRequest, + signers::local::PrivateKeySigner, +}; +use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; +use alloy_sol_types::{sol, SolCall}; +use anyhow::{anyhow, bail, ensure, Context, Result}; +use fs2::FileExt; +use m3tering_protocol::transition::{self, Input, Transition}; +use m3tering_protocol::SignedAction; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + str::FromStr, +}; + +sol! { function commitState(bytes updates, bytes proof); } +#[derive(Clone)] +struct Rpc { + url: String, + client: reqwest::Client, +} +impl Rpc { + async fn call(&self, method: &str, params: Value) -> Result { + // Do not log URLs: they may embed credentials. + let response = self + .client + .post(&self.url) + .json(&json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})) + .send() + .await + .map_err(|_| anyhow!("RPC transport failed"))?; + ensure!( + response.status().is_success(), + "RPC HTTP error {}", + response.status() + ); + let v: Value = response.json().await?; + if let Some(e) = v.get("error") { + bail!("{method}: {e}"); + } + v.get("result") + .cloned() + .filter(|v| !v.is_null()) + .ok_or_else(|| anyhow!("{method}: missing result")) + } + async fn view(&self, to: Address, name: &str, block: Value) -> Result { + let data = format!( + "0x{}", + hex::encode(&keccak256(format!("{name}()")).as_slice()[..4]) + ); + quantity( + &self + .call("eth_call", json!([{"to":to,"data":data},block])) + .await?, + ) + } + async fn view_arg( + &self, + to: Address, + signature: &str, + arg: U256, + block: Value, + ) -> Result { + let mut data = keccak256(signature).as_slice()[..4].to_vec(); + data.extend(arg.to_be_bytes::<32>()); + quantity( + &self + .call( + "eth_call", + json!([{"to":to,"data":format!("0x{}",hex::encode(data))},block]), + ) + .await?, + ) + } +} +fn quantity(value: &Value) -> Result { + U256::from_str( + value + .as_str() + .ok_or_else(|| anyhow!("expected hex quantity"))?, + ) + .map_err(Into::into) +} +fn env_u64(name: &str, default: u64) -> Result { + Ok(std::env::var(name) + .map(|s| s.parse()) + .unwrap_or(Ok(default))?) +} +fn required(name: &str) -> Result { + std::env::var(name).with_context(|| format!("set {name}")) +} +fn budget(block_limit: u64) -> Result { + let post_repricing = match std::env::var("GAS_SCHEDULE").as_deref() { + Ok("glamsterdam-model") => true, + Ok("current") | Err(_) => false, + _ => bail!("GAS_SCHEDULE must be current or glamsterdam-model"), + }; + let execution_limit = env_u64("EXECUTION_GAS_BUDGET", 15_000_000)? + .min(16_777_216) + .min(block_limit); + let total_limit = env_u64("TOTAL_GAS_BUDGET", 15_000_000)? + .min(block_limit) + .min(if post_repricing { + u64::MAX + } else { + execution_limit + }); + Ok(budget::Budget { + execution_limit, + total_limit, + fixed_execution: env_u64("FIXED_EXECUTION_ALLOWANCE", 1_500_000)?, + per_word_overhead: env_u64("PER_WORD_OVERHEAD_ALLOWANCE", 12_000)?, + post_repricing, + }) +} +async fn actions() -> Result>> { + if let Ok(path) = std::env::var("ACTIONS_FILE") { + return Ok(serde_json::from_slice(&std::fs::read(path)?)?); + } + let (client, connection) = + tokio_postgres::connect(&required("DATABASE_URL")?, tokio_postgres::NoTls).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("database connection: {e}"); + } + }); + let limit = i64::try_from(env_u64("MAX_ACTIONS", 10_000)?)?; + let rows=client.query("SELECT m3ter_id,message,signature FROM m3ter_payloads WHERE is_verified = false ORDER BY m3ter_id,nonce,id LIMIT $1",&[&limit]).await?; + let mut result = BTreeMap::>::new(); + for row in rows { + let id: i64 = row.get(0); + result + .entry(u64::try_from(id)?) + .or_default() + .push(SignedAction { + message: row.get(1), + signature: row.get(2), + }); + } + Ok(result) +} +async fn plan(rpc: &Rpc, rollup: Address) -> Result<(Input, Transition, budget::Budget)> { + let mut pending = actions().await?; + let mut remaining = usize::try_from(env_u64("MAX_ACTIONS", 10000)?)?; + pending.retain(|_, a| { + a.truncate(remaining); + remaining -= a.len(); + !a.is_empty() + }); + let max = usize::try_from(env_u64("MAX_SLOTS", 4096)?)?; + ensure!(max > 0 && max <= 4096, "invalid MAX_SLOTS"); + let indices: BTreeSet = pending.keys().map(|m| m / 4).collect(); + let head = rpc + .call("eth_getBlockByNumber", json!(["latest", false])) + .await?; + let read_hash: B256 = serde_json::from_value(head["hash"].clone())?; + let tag = json!({"blockHash":read_hash,"requireCanonical":true}); + let chain_id = quantity(&rpc.call("eth_chainId", json!([])).await?)?; + let b = budget(quantity(&head["gasLimit"])?.try_into()?)?; + println!("Conservative gas admission: at most {} reused words or {} fresh words (before MAX_SLOTS/action limits)", b.max_homogeneous(false,4096), b.max_homogeneous(true,4096)); + let mut words = BTreeMap::new(); + let mut keys = BTreeMap::new(); + let mut costs = Vec::new(); + let word_count = rpc.view(rollup, "wordCount", tag.clone()).await?; + ensure!( + indices.iter().all(|i| *i <= transition::MAX_SLOT), + "id exceeds uint32 slot" + ); + for slot in indices.into_iter().take(max) { + let old = if U256::from(slot) < word_count { + rpc.view_arg(rollup, "words(uint256)", U256::from(slot), tag.clone()) + .await? + } else { + U256::ZERO + }; + costs.push(old); + if !b.fits(&costs) { + costs.pop(); + break; + } + let mut group = [B256::ZERO; 4]; + for pair in 0..4 { + if !pending.contains_key(&(slot * 4 + pair as u64)) { + continue; + } + group[pair] = B256::from( + rpc.view_arg( + rollup, + "publicKey(uint64)", + U256::from(slot * 4 + pair as u64), + tag.clone(), + ) + .await? + .to_be_bytes::<32>(), + ); + } + words.insert(slot, old); + keys.insert(slot, group); + } + pending.retain(|m, _| words.contains_key(&(m / 4))); + ensure!(!pending.is_empty(), "no actions fit gas budgets"); + let input = Input { + chain_id, + rollup, + words, + keys, + actions: pending, + }; + let t = transition::execute(&input).map_err(|e| anyhow!(e))?; + println!( + "Planned {} words, {} packed payload bytes, 32 public-value bytes", + t.updates.len(), + t.payload.len() + ); + Ok((input, t, b)) +} +async fn preflight(rpc: &Rpc, input: &Input, t: &Transition) -> Result<()> { + let head = rpc + .call("eth_getBlockByNumber", json!(["latest", false])) + .await?; + let tag = json!({"blockHash":head["hash"],"requireCanonical":true}); + let word_count = rpc.view(input.rollup, "wordCount", tag.clone()).await?; + ensure!( + quantity(&rpc.call("eth_chainId", json!([])).await?)? == input.chain_id, + "chain changed" + ); + for u in &t.updates { + let current = if U256::from(u.slot) < word_count { + rpc.view_arg( + input.rollup, + "words(uint256)", + U256::from(u.slot), + tag.clone(), + ) + .await? + } else { + U256::ZERO + }; + ensure!(current == u.old, "old word changed"); + for pair in 0..4 { + if transition::unpack(u.old, pair as u64) == transition::unpack(u.new, pair as u64) { + continue; + } + ensure!( + B256::from( + rpc.view_arg( + input.rollup, + "publicKey(uint64)", + U256::from(u.slot * 4 + pair as u64), + tag.clone() + ) + .await? + .to_be_bytes::<32>() + ) == input.keys[&u.slot][pair], + "key rotated; rebuild proof" + ); + } + } + Ok(()) +} +async fn funding(rpc: &Rpc, signer: Address, gas: u64) -> Result<(u128, u128)> { + let max_fee: u128 = required("MAX_FEE_PER_GAS_WEI")?.parse()?; + let priority: u128 = required("MAX_PRIORITY_FEE_PER_GAS_WEI")?.parse()?; + ensure!(priority <= max_fee, "priority fee exceeds max fee"); + let head = rpc + .call("eth_getBlockByNumber", json!(["latest", false])) + .await?; + let base: u128 = quantity(&head["baseFeePerGas"])?.try_into()?; + ensure!( + base.checked_add(priority).is_some_and(|f| f <= max_fee), + "fee cap below current base fee + priority" + ); + let balance = quantity( + &rpc.call("eth_getBalance", json!([signer, "pending"])) + .await?, + )?; + let reserve = U256::from_str(&std::env::var("ETH_RESERVE_WEI").unwrap_or_else(|_| "0".into()))?; + budget::require_funding(balance, gas, max_fee, reserve)?; + Ok((max_fee, priority)) +} +#[cfg(feature = "proving")] +async fn prove_and_send( + rpc: &Rpc, + input: &Input, + t: &Transition, + b: budget::Budget, + out: &Path, +) -> Result<()> { + use sp1_sdk::{ + network::{signer::NetworkSigner, NetworkMode}, + Elf, HashableKey, ProveRequest, Prover, ProverClient, ProvingKey, SP1ProofMode, SP1Stdin, + }; + let signer = PrivateKeySigner::from_str(&required("PRIVATE_KEY")?)?; + // An OS lock serializes cooperating processes sharing a submission wallet. + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(std::env::temp_dir().join(format!("m3ter-prover-{}.lock", signer.address())))?; + lock.try_lock_exclusive() + .context("another prover batch is using this wallet")?; + preflight(rpc, input, t).await?; + let (_, estimated) = b.estimate(&t.updates.iter().map(|u| u.old).collect::>())?; + let estimated = estimated + .checked_mul(6) + .and_then(|x| x.checked_add(4)) + .map(|x| x / 5) + .ok_or_else(|| anyhow!("gas budget overflow"))?; + funding(rpc, signer.address(), estimated).await?; + let network_key = required("SP1_PRIVATE_KEY")?; + let network_signer = NetworkSigner::local(&network_key)?; + let client = ProverClient::builder() + .network_for(NetworkMode::Mainnet) + .signer(network_signer) + .build() + .await; + let elf = Elf::from(std::fs::read(required("SP1_ELF")?)?); + let pk = client.setup(elf.clone()).await?; + let onchain = rpc + .view(input.rollup, "SP1_PROGRAM_VKEY", json!("latest")) + .await?; + ensure!( + B256::from(onchain.to_be_bytes::<32>()) == B256::from_str(&pk.verifying_key().bytes32())?, + "deployed program key differs from prover guest" + ); + let mut stdin = SP1Stdin::new(); + stdin.write(input); + // Execute locally before requesting a paid proof. Compare complete public values. + let (public, report) = client + .execute(elf, stdin.clone()) + .cycle_limit(env_u64("MAX_PROVER_CYCLES", 100_000_000)?) + .calculate_gas(true) + .await?; + ensure!( + public.as_slice() == t.public_values.as_slice(), + "guest/host statement mismatch" + ); + let cycles = report + .total_instruction_count() + .checked_mul(110) + .and_then(|v| v.checked_div(100)) + .ok_or_else(|| anyhow!("cycle overflow"))?; + let pgu = report + .gas() + .ok_or_else(|| anyhow!("SP1 execution did not report PGU"))? + .checked_mul(110) + .and_then(|v| v.checked_div(100)) + .ok_or_else(|| anyhow!("PGU overflow"))?; + ensure!( + cycles <= env_u64("MAX_PROVER_CYCLES", 100_000_000)? + && pgu <= env_u64("MAX_PROVER_PGU", 100_000_000)?, + "batch exceeds configured proving budget; reduce MAX_SLOTS/MAX_ACTIONS" + ); + let price = required("MAX_PRICE_PER_PGU")?.parse::()?; + let params = client + .get_proof_request_params(SP1ProofMode::Groth16) + .await?; + let params = match params { + sp1_sdk::network::proto::GetProofRequestParamsResponse::Auction(p) => p, + _ => bail!("network does not provide auction prices"), + }; + ensure!( + params.tick_size == 0 || price % params.tick_size == 0, + "MAX_PRICE_PER_PGU must be a multiple of the auction tick" + ); + let base = params.base_fee.parse::()?; + let reserve = U256::from_str(&required("SP1_CREDIT_RESERVE_WEI")?)?; + let cost = U256::from(pgu) + .checked_mul(U256::from(price)) + .and_then(|x| x.checked_add(U256::from(base))) + .and_then(|x| x.checked_add(reserve)) + .ok_or_else(|| anyhow!("credit cost overflow"))?; + ensure!( + client.get_balance().await? >= cost, + "insufficient SP1 network credits (PROVE wei)" + ); + preflight(rpc, input, t).await?; + funding(rpc, signer.address(), estimated).await?; + let proof = client + .prove(&pk, stdin) + .groth16() + .cycle_limit(cycles) + .gas_limit(pgu) + .max_price_per_pgu(price) + .timeout(std::time::Duration::from_secs(env_u64( + "PROOF_TIMEOUT_SECONDS", + 3600, + )?)) + .await?; + client.verify(&proof, pk.verifying_key(), None)?; + ensure!( + proof.public_values.as_slice() == t.public_values.as_slice(), + "proof statement mismatch" + ); + proof.save(out.join("proof.bin"))?; + preflight(rpc, input, t).await?; + let data = commitStateCall { + updates: t.payload.clone().into(), + proof: proof.bytes().into(), + } + .abi_encode(); + let request = json!({"from":signer.address(),"to":input.rollup,"data":format!("0x{}",hex::encode(&data))}); + rpc.call("eth_call", json!([request, "pending"])).await?; + let actual: u64 = + quantity(&rpc.call("eth_estimateGas", json!([request])).await?)?.try_into()?; + let gas = actual + .checked_mul(120) + .and_then(|v| v.checked_div(100)) + .ok_or_else(|| anyhow!("gas overflow"))?; + ensure!( + gas <= b.total_limit, + "final gas with 20% headroom exceeds budget; keep proof, do not send" + ); + let (max_fee, priority) = funding(rpc, signer.address(), gas).await?; + let provider = ProviderBuilder::new() + .wallet(signer) + .connect_http(rpc.url.parse()?); + let tx = TransactionRequest::default() + .to(input.rollup) + .input(Bytes::from(data).into()) + .gas_limit(gas) + .max_fee_per_gas(max_fee) + .max_priority_fee_per_gas(priority); + let pending = provider.send_transaction(tx).await?; + std::fs::write( + out.join("transaction.json"), + serde_json::to_vec_pretty(&json!({"hash":pending.tx_hash()}))?, + )?; + let receipt = pending + .with_required_confirmations(env_u64("CONFIRMATIONS", 2)?) + .get_receipt() + .await?; + ensure!( + receipt.status(), + "commit reverted; do not mark actions verified" + ); + std::fs::write( + out.join("receipt.json"), + serde_json::to_vec_pretty(&receipt)?, + )?; + println!("Committed {}", receipt.transaction_hash); + if std::env::var_os("ACTIONS_FILE").is_none() { + // Only mark the selected messages consumed, and only after a successful, + // still-canonical confirmed receipt. No new proof => no DB mutation. + let number = receipt + .block_number + .ok_or_else(|| anyhow!("receipt missing block number"))?; + let canonical = rpc + .call( + "eth_getBlockByNumber", + json!([format!("0x{number:x}"), false]), + ) + .await?; + ensure!( + Some(serde_json::from_value::(canonical["hash"].clone())?) == receipt.block_hash, + "receipt reorged before database reconciliation" + ); + reconcile(input, t).await?; + } + Ok(()) +} +#[cfg(feature = "proving")] +async fn reconcile(input: &Input, t: &Transition) -> Result<()> { + let (mut client, connection) = + tokio_postgres::connect(&required("DATABASE_URL")?, tokio_postgres::NoTls).await?; + tokio::spawn(async move { + let _ = connection.await; + }); + let transaction = client.transaction().await?; + for (id, actions) in &input.actions { + let Some(update) = t.updates.iter().find(|u| u.slot == id / 4) else { + continue; + }; + let (_, nonce) = transition::unpack(update.new, id % 4); + for action in actions { + let bytes = hex::decode(action.message.strip_prefix("0x").unwrap_or(&action.message))?; + ensure!(bytes.len() == 8, "invalid signed message"); + if u32::from_be_bytes(bytes[..4].try_into()?) <= nonce { + let id = i64::try_from(*id)?; + transaction.execute("UPDATE m3ter_payloads SET is_verified=true WHERE m3ter_id=$1 AND message=$2 AND signature=$3 AND is_verified=false",&[&id,&action.message,&action.signature]).await?; + } + } + } + transaction.commit().await?; + Ok(()) +} +#[cfg(feature = "proving")] +async fn execute_fixture() -> Result<()> { + use sp1_sdk::{Elf, HashableKey, Prover, ProverClient, ProvingKey, SP1Stdin}; + let input: Input = serde_json::from_slice(&std::fs::read(required("WITNESS_FILE")?)?)?; + let expected = transition::execute(&input).map_err(|e| anyhow!(e))?; + let client = ProverClient::builder().light().build().await; + let elf = Elf::from(std::fs::read(required("SP1_ELF")?)?); + let pk = client.setup(elf.clone()).await?; + println!("Program verification key: {}", pk.verifying_key().bytes32()); + let mut stdin = SP1Stdin::new(); + stdin.write(&input); + let (public, report) = client + .execute(elf, stdin) + .cycle_limit(env_u64("MAX_PROVER_CYCLES", 100_000_000)?) + .calculate_gas(true) + .await?; + ensure!( + public.as_slice() == expected.public_values.as_slice(), + "SP1 guest public values differ from host" + ); + println!( + "SP1 local execution: {} cycles; {:?} PGU", + report.total_instruction_count(), + report.gas() + ); + Ok(()) +} +#[tokio::main] +async fn main() -> Result<()> { + let mode = std::env::args().nth(1).unwrap_or_else(|| "plan".into()); + #[cfg(feature = "proving")] + if mode == "execute-fixture" { + return execute_fixture().await; + } + ensure!( + mode == "plan" || mode == "run", + "usage: prover-rollup-host [plan|run]" + ); + let rpc = Rpc { + url: required("RPC_URL")?, + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build()?, + }; + let rollup = required("ROLLUP_ADDRESS")?.parse()?; + let out = std::path::PathBuf::from( + std::env::var("OUTPUT_DIR").unwrap_or_else(|_| "prover-output".into()), + ); + std::fs::create_dir_all(&out)?; + ensure!(!out.join("proof.bin").exists() && !out.join("transaction.json").exists(),"OUTPUT_DIR contains a paid proof or transaction: choose a new directory; do not overwrite it"); + let (input, t, b) = plan(&rpc, rollup).await?; + std::fs::write(out.join("input.json"), serde_json::to_vec_pretty(&input)?)?; + std::fs::write(out.join("transition.json"), serde_json::to_vec_pretty(&t)?)?; + if mode == "run" { + #[cfg(feature = "proving")] + prove_and_send(&rpc, &input, &t, b, &out).await?; + #[cfg(not(feature = "proving"))] + { + let _ = b; + bail!("rebuild with --features proving to submit paid requests"); + } + } + Ok(()) +} diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 746d922..a964e7e 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -1,16 +1,13 @@ [package] -name = "energy-tracker-lib" +name = "m3tering-protocol" version = "0.1.0" edition = "2021" [dependencies] -alloy-sol-types = { workspace = true } alloy-primitives = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } hex = { workspace = true } -alloy-rlp = { workspace = true } ed25519-dalek = { workspace = true } -alloy-trie = "0.8.1" -alloy-consensus = "1.0.16" -rayon = "1.11.0" + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/lib/src/accounting.rs b/lib/src/accounting.rs new file mode 100644 index 0000000..c2c9467 --- /dev/null +++ b/lib/src/accounting.rs @@ -0,0 +1,112 @@ +//! Meter accounting: uint40 Wh + uint24 nonce. Wire messages remain u32 nonce/mWh. +use crate::SignedAction; +use ed25519_dalek::{Signature, VerifyingKey}; +pub const MAX_ACCOUNT: u64 = (1u64 << 40) - 1; +pub const MAX_NONCE: u32 = (1u32 << 24) - 1; +/// Aggregate accepted mWh, then truncate once per meter per commit (per-commit policy). +pub fn apply( + account: u64, + mut nonce: u32, + public_key: [u8; 32], + actions: &[SignedAction], +) -> Result<(u64, u32), String> { + if account > MAX_ACCOUNT || nonce > MAX_NONCE { + return Err("invalid initial packed fields".into()); + } + let key = VerifyingKey::from_bytes(&public_key).map_err(|_| "invalid public key")?; + let mut milliwh = 0u64; + for action in actions { + let msg = hex::decode(action.message.strip_prefix("0x").unwrap_or(&action.message)) + .map_err(|_| "invalid message hex")?; + if msg.len() != 8 { + return Err("signed message must contain exactly eight bytes".into()); + } + let next = u32::from_be_bytes(msg[..4].try_into().unwrap()); + let energy = u32::from_be_bytes(msg[4..].try_into().unwrap()); + if next <= nonce { + continue; + } + if nonce.checked_add(1) != Some(next) { + break; + } + let signature = hex::decode( + action + .signature + .strip_prefix("0x") + .unwrap_or(&action.signature), + ) + .ok() + .and_then(|b| Signature::from_slice(&b).ok()); + if !signature.is_some_and(|s| key.verify_strict(&msg, &s).is_ok()) { + break; + } + if next > MAX_NONCE { + return Err("uint24 nonce capacity exhausted".into()); + } + milliwh = milliwh + .checked_add(u64::from(energy)) + .ok_or("mWh aggregate overflow")?; + nonce = next; + } + let account = account + .checked_add(milliwh / 1000) + .filter(|a| *a <= MAX_ACCOUNT) + .ok_or("uint40 Wh capacity exhausted")?; + Ok((account, nonce)) +} +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + fn action(key: &SigningKey, n: u32, e: u32) -> SignedAction { + let msg = [n.to_be_bytes(), e.to_be_bytes()].concat(); + SignedAction { + message: hex::encode(&msg), + signature: hex::encode(key.sign(&msg).to_bytes()), + } + } + #[test] + fn aggregate_before_truncation() { + let k = SigningKey::from_bytes(&[42; 32]); + assert_eq!( + apply( + 0, + 0, + k.verifying_key().to_bytes(), + &[action(&k, 1, 600), action(&k, 2, 600)] + ) + .unwrap(), + (1, 2) + ); + } + #[test] + fn explicitly_discards_remainder_between_commits() { + let k = SigningKey::from_bytes(&[42; 32]); + let (a, n) = apply(0, 0, k.verifying_key().to_bytes(), &[action(&k, 1, 600)]).unwrap(); + assert_eq!( + apply(a, n, k.verifying_key().to_bytes(), &[action(&k, 2, 600)]).unwrap(), + (0, 2) + ); + } + #[test] + fn units_and_caps() { + let k = SigningKey::from_bytes(&[42; 32]); + let pk = k.verifying_key().to_bytes(); + assert_eq!( + apply(0, 0, pk, &[action(&k, 1, 1_000_000)]).unwrap(), + (1000, 1) + ); + assert!(apply(MAX_ACCOUNT, 0, pk, &[action(&k, 1, 1000)]).is_err()); + assert!(apply(0, MAX_NONCE, pk, &[action(&k, MAX_NONCE + 1, 1)]).is_err()); + } + #[test] + fn signed_accounting_only() { + let k = SigningKey::from_bytes(&[42; 32]); + let mut a = action(&k, 1, 1000); + a.message = "00000001000007d0".into(); + assert_eq!( + apply(0, 0, k.verifying_key().to_bytes(), &[a]).unwrap(), + (0, 0) + ); + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 8905ac6..9ff6fc2 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,227 +1,11 @@ -use std::{collections::HashMap, fmt::Debug}; +//! Proven meter accounting and the Rollup wire protocol. +use serde::{Deserialize, Serialize}; -use alloy_primitives::{Bytes, B256, U256}; -use alloy_sol_types::sol; -use alloy_trie::Nibbles; -use ed25519_dalek::VerifyingKey; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +pub mod accounting; +pub mod transition; -mod util; -use util::validate_signature; - -pub use util::{ - calc_slot_key, decode_slice, destructure_payload, extract_nonce, get_state_root, to_b256, - to_keccak_hash, to_u256, trim_zeros, verify_account_proof, -}; - -sol! { - #[derive(Serialize, Deserialize, Debug)] - struct PublicValuesStruct { - bytes32 block_hash; - bytes32 previous_balances; - bytes32 previous_nonces; - bytes new_balances; - bytes new_nonces; - } -} - -impl PublicValuesStruct { - pub fn concat_bytes(&self) -> Vec { - let mut bytes = Vec::new(); - bytes.extend(self.block_hash.as_slice()); - bytes.extend(self.previous_balances.as_slice()); - bytes.extend(self.previous_nonces.as_slice()); - bytes.extend(self.new_balances.clone()); - bytes.extend(self.new_nonces.clone()); - bytes - } - - pub fn from_bytes(bytes: &[u8]) -> Self { - let block_hash = B256::from_slice(&bytes[0..32]); - let previous_balances = B256::from_slice(&bytes[32..64]); - let previous_nonces = B256::from_slice(&bytes[64..96]); - let update_values = &bytes[96..]; - let split_point = update_values.len() / 2; - let new_balances = update_values[1..split_point].to_vec().into(); - let new_nonces = update_values[(split_point + 1)..].to_vec().into(); - - PublicValuesStruct { - block_hash, - previous_balances, - previous_nonces, - new_balances, - new_nonces, - } - } -} - -fn deserialize_hex<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let s: String = Deserialize::deserialize(deserializer)?; - let s = if s.starts_with("0x") { - s.strip_prefix("0x").unwrap() - } else { - s.as_str() - }; - hex::decode(s).map_err(serde::de::Error::custom) -} - -pub fn serialize_hex(bytes: &Vec, serializer: S) -> Result -where - S: Serializer, -{ - let hex_str = hex::encode(bytes); - serializer.serialize_str(&hex_str) -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct ProofStruct { - pub storage_hash: B256, - pub proofs: HashMap)>, - pub encoded_account: Vec, - pub account_proof: Vec, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct Payload { - pub mempool: HashMap>, - #[serde(deserialize_with = "deserialize_hex", serialize_with = "serialize_hex")] - pub previous_nonces: Vec, - #[serde(deserialize_with = "deserialize_hex", serialize_with = "serialize_hex")] - pub previous_balances: Vec, - pub proofs: ProofStruct, - pub block_bytes: Vec, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct M3terRawPayload(String); - -impl M3terRawPayload { - pub fn to_m3ter_payload(&self) -> M3terPayload { - let (message, signature, nonce, energy) = util::destructure_payload(&self.0); - M3terPayload::new(message.to_string(), signature.to_string(), nonce, energy) - } -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct M3terPayload { - message: String, - signature: String, - nonce: u64, - energy: u64, -} - -impl M3terPayload { - pub fn new(message: String, signature: String, nonce: u64, energy: u64) -> Self { - M3terPayload { - message, - signature, - nonce, - energy, - } - } - - fn _msg_to_vec(&self) -> Vec { - hex::decode(self.message.clone()).expect("Failed to decode hex") - } -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct M3ter { - pub m3ter_id: String, - pub public_key: String, -} - -impl M3ter { - pub fn new(m3ter_id: &str, public_key: &str) -> Self { - M3ter { - m3ter_id: String::from(m3ter_id), - public_key: String::from(public_key), - } - } - - fn validate_payload(&self, payload: &M3terPayload, verifying_key: VerifyingKey) -> bool { - match validate_signature(&payload.message, &payload.signature, verifying_key) { - Some(is_valid) => is_valid, - None => { - println!("Invalid signature for payload: {:?}", payload); - false - } - } - } - - fn verify_public_key(&self, storage_hash: &B256, proof: &Vec) -> bool { - println!("storage hash = {:?}\nproof = {:?}", storage_hash, proof); - let (m3ter_id, public_key) = (&self.m3ter_id, &self.public_key); - let m3ter_id = m3ter_id.parse::().expect("invalid m3ter id"); - let slot_key = calc_slot_key(U256::from(m3ter_id)).expect("invalid slot key"); - - let slot_key = Nibbles::unpack(to_keccak_hash(slot_key.to_be_bytes_vec())); - let public_key = if public_key.starts_with("0x") { - public_key.strip_prefix("0x").unwrap() - } else { - public_key.as_str() - }; - - let expected_value = U256::from_be_slice(&hex::decode(public_key).unwrap()); - println!("expected_value = {:?}", expected_value); - let expected_value = alloy_rlp::encode(expected_value); - let result = - alloy_trie::proof::verify_proof(*storage_hash, slot_key, Some(expected_value), proof); - match result { - Ok(()) => true, - Err(err) => { - println!("Failed to verify proof: {:?}", err); - false - } - } - } -} - -pub fn track_energy( - m3ter: M3ter, - m3ter_payloads: &[M3terPayload], - start_nonce: u64, - (storage_hash, proof): (&B256, &Vec), -) -> (u64, u64) { - if !m3ter.verify_public_key(storage_hash, proof) { - println!( - "encountered invalid public_key for m3ter {}", - m3ter.m3ter_id - ); - return (0, start_nonce); - } - - let verifying_key = util::build_verifying_key(&m3ter.public_key).unwrap(); - let mut nonce = start_nonce; - let mut energy_sum = 0u64; - for payload in m3ter_payloads { - println!("nonce {}, payload nonce {}", nonce, payload.nonce); - if nonce >= payload.nonce { - continue; - } - if nonce + 1 != payload.nonce { - println!( - "Invalid nonce: {} not consercutive to {} for m3ter_id {}", - &nonce, &payload.nonce, &m3ter.m3ter_id - ); - break; - } - if !m3ter.validate_payload(payload, verifying_key) { - println!("Invalid payload: {:?}", payload); - break; - }; - - nonce = payload.nonce; - energy_sum += payload.energy; - println!( - "State: energy {:?}, nonce {:?}", - payload.energy, payload.nonce - ); - } - - (energy_sum, nonce) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SignedAction { + pub message: String, + pub signature: String, } diff --git a/lib/src/transition.rs b/lib/src/transition.rs new file mode 100644 index 0000000..9d6bcfb --- /dev/null +++ b/lib/src/transition.rs @@ -0,0 +1,163 @@ +//! Authenticated packed-state transitions and the Rollup statement codec. +use crate::{accounting, SignedAction}; +use alloy_primitives::{keccak256, Address, B256, U256}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +pub const MAX_SLOT: u64 = u32::MAX as u64; +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Input { + pub chain_id: U256, + pub rollup: Address, + pub words: BTreeMap, + pub keys: BTreeMap, + pub actions: BTreeMap>, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Update { + pub slot: u64, + pub old: U256, + pub new: U256, + pub keys_hash: B256, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Transition { + pub public_values: B256, + pub updates: Vec, + pub payload: Vec, +} +pub fn unpack(word: U256, pair: u64) -> (u64, u32) { + assert!(pair < 4); + let value = ((word >> (pair as usize * 64)) & U256::from(u64::MAX)).to::(); + (value & accounting::MAX_ACCOUNT, (value >> 40) as u32) +} +fn replace(word: U256, pair: u64, account: u64, nonce: u32) -> U256 { + let shift = pair as usize * 64; + (word & !(U256::from(u64::MAX) << shift)) + | ((U256::from(account) | (U256::from(nonce) << 40)) << shift) +} +pub fn keys_hash(keys: &[B256; 4]) -> B256 { + let mut bytes = Vec::with_capacity(128); + for key in keys { + bytes.extend(key.as_slice()); + } + keccak256(bytes) +} +pub fn statement(input: &Input, updates: &[Update]) -> B256 { + let mut bytes = Vec::with_capacity(52 + 100 * updates.len()); + bytes.extend(input.chain_id.to_be_bytes::<32>()); + bytes.extend(input.rollup.as_slice()); + for u in updates { + bytes.extend(&u.slot.to_be_bytes()[4..]); + bytes.extend(u.old.to_be_bytes::<32>()); + bytes.extend(u.new.to_be_bytes::<32>()); + bytes.extend(u.keys_hash.as_slice()); + } + keccak256(bytes) +} +pub fn execute(input: &Input) -> Result { + let mut grouped: BTreeMap)>> = BTreeMap::new(); + for (m, a) in &input.actions { + if m / 4 > MAX_SLOT { + return Err("id exceeds uint32 slot".into()); + } + grouped.entry(m / 4).or_default().push((*m, a)); + } + let mut updates = Vec::new(); + for (slot, meters) in grouped { + let old = *input.words.get(&slot).ok_or("missing old word")?; + let mut new = old; + let keys = input.keys.get(&slot).ok_or("missing key group")?; + for (id, actions) in meters { + let key = keys[(id % 4) as usize]; + if key == B256::ZERO { + return Err("unregistered id".into()); + } + let (account, nonce) = unpack(new, id % 4); + let (account, nonce) = accounting::apply(account, nonce, key.0, actions)?; + new = replace(new, id % 4, account, nonce); + } + if new != old { + let mut used_keys = *keys; + for pair in 0..4 { + if unpack(old, pair) == unpack(new, pair) { + used_keys[pair as usize] = B256::ZERO; + } + } + updates.push(Update { + slot, + old, + new, + keys_hash: keys_hash(&used_keys), + }); + } + } + if updates.is_empty() { + return Err("no state changes".into()); + } + if updates.len() > u32::MAX as usize { + return Err("too many updates".into()); + } + let public_values = statement(input, &updates); + let payload = encode(&updates); + Ok(Transition { + public_values, + updates, + payload, + }) +} +/// Fixed 36-byte records: uint32 slot, uint256 absolute new word, both big endian. +pub fn encode(updates: &[Update]) -> Vec { + let mut bytes = Vec::with_capacity(36 * updates.len()); + for u in updates { + assert!(u.slot <= MAX_SLOT, "slot exceeds uint32"); + bytes.extend(&u.slot.to_be_bytes()[4..]); + bytes.extend(u.new.to_be_bytes::<32>()); + } + bytes +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn pair_boundaries() { + let mut w = U256::ZERO; + for p in 0..4 { + w = replace( + w, + p, + accounting::MAX_ACCOUNT - p, + accounting::MAX_NONCE - p as u32, + ); + } + for p in 0..4 { + assert_eq!( + unpack(w, p), + ( + accounting::MAX_ACCOUNT - p, + accounting::MAX_NONCE - p as u32 + ) + ); + } + } + #[test] + fn fixed_records_include_every_index() { + let u = vec![ + Update { + slot: 2, + old: U256::ZERO, + new: U256::from(1), + keys_hash: B256::ZERO, + }, + Update { + slot: 3, + old: U256::ZERO, + new: U256::from(1), + keys_hash: B256::ZERO, + }, + ]; + let b = encode(&u); + assert_eq!(b.len(), 72); + assert_eq!(&b[..4], &[0, 0, 0, 2]); + assert_eq!(&b[36..40], &[0, 0, 0, 3]); + } +} diff --git a/lib/src/util.rs b/lib/src/util.rs deleted file mode 100644 index 8b53b4f..0000000 --- a/lib/src/util.rs +++ /dev/null @@ -1,150 +0,0 @@ -use std::str::FromStr; - -use alloy_consensus::Header; -use alloy_primitives::{keccak256, Bytes, B256, U256}; -use alloy_trie::Nibbles; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - -pub fn validate_signature( - message_hash: &str, - signature_str: &str, - verifying_key: VerifyingKey, -) -> Option { - // Decode the signature and public key using ed25519-dalek - let signature = build_signature(signature_str)?; - // Verify the signature using ed25519-dalek - match verifying_key.verify(&hex::decode(message_hash).unwrap(), &signature) { - Ok(()) => Some(true), - Err(err) => { - println!("Signature verification failed with err {:?}", err); - None - } - } -} - -fn build_signature(raw_signature: &str) -> Option { - let signature = Signature::from_slice(&decode_hex(raw_signature)); - match signature { - Ok(sign) => Some(sign), - Err(err) => { - print!("failed to build from slice with err {:?}", err); - None - } - } -} - -pub fn build_verifying_key(raw_public_key: &str) -> Option { - let key = B256::from_str(raw_public_key.strip_prefix("0x").unwrap()).unwrap(); - let verifying_key = VerifyingKey::from_bytes(&key); - match verifying_key { - Ok(verifier) => Some(verifier), - Err(err) => { - print!("failed to build from slice with err {:?}", err); - None - } - } -} - -fn decode_hex(data: &str) -> Vec { - match hex::decode(data) { - Ok(data) => data, - Err(error) => panic!("failed with error {}", error), - } -} - -pub fn get_state_root(block_bytes: &Vec) -> B256 { - let block_header = - alloy_rlp::decode_exact::
(block_bytes).expect("Failed to decode block header"); - - block_header.state_root -} - -pub fn verify_account_proof( - state_root: B256, - address: Vec, - expected_value: Vec, - proof: Vec, -) -> bool { - let address = Nibbles::unpack(to_keccak_hash(address)); - alloy_trie::proof::verify_proof(state_root, address, Some(expected_value), &proof).is_ok() -} - -pub fn destructure_payload(payload: &str) -> (&str, &str, u64, u64) { - let payload_bytes = hex::decode(payload).expect("Failed to decode hex payload"); - let (message, signature) = payload.split_at(16); - let signature = if signature.len() > 128 { &signature[0..128] } else {signature }; - let nonce_bytes: [u8; 4] = payload_bytes[0..4] - .try_into() - .expect("Failed to get nonce bytes"); - let energy_bytes: [u8; 4] = payload_bytes[4..8] - .try_into() - .expect("Failed to get energy bytes"); - let nonce = u32::from_be_bytes(nonce_bytes) as u64; - let energy = u32::from_be_bytes(energy_bytes) as u64; - (message, signature, nonce, energy) -} - -pub fn decode_slice(data: &[u8; 6]) -> u64 { - // Convert 6 bytes to i64 (big-endian, pad with zeros) - let mut buf = [0u8; 8]; - buf[2..].copy_from_slice(data); // pad the first 2 bytes with zeros - u64::from_be_bytes(buf) -} - -pub fn extract_nonce(payload: &str) -> i64 { - println!("payload {}", payload); - let payload_bytes = hex::decode(payload).expect("Failed to decode hex payload"); - let nonce_bytes: [u8; 4] = payload_bytes[0..4] - .try_into() - .expect("Failed to get nonce bytes"); - i32::from_be_bytes(nonce_bytes) as i64 -} - -pub fn trim_zeros(value: Vec) -> Vec { - let mut value = value; - if value.is_empty() { - return value; - } - loop { - let mut buf = [0u8; 8]; - let data: [u8; 6] = value[(value.len() - 6)..].try_into().unwrap(); - buf[2..].copy_from_slice(&data); // pad the first 2 bytes with zeros - if u64::from_be_bytes(buf) == 0u64 { - value = value.strip_suffix(&[0u8; 6]).unwrap().to_vec(); - } else { - break; - } - } - value -} - -pub fn to_b256(value: U256) -> B256 { - B256::from_slice(&value.to_be_bytes_vec()) -} - -pub fn to_u256(value: u64) -> U256 { - U256::from(value) -} - -pub fn to_keccak_hash(input: Vec) -> B256 { - keccak256(input) -} - -pub fn calc_slot_key(key: U256) -> Option { - let slot_literal: U256 = - "97075990194835763561528983445257952440596761921281503889599705229225710478219" - .parse() - .expect("invalid slot literal"); - - key.checked_add(slot_literal) -} - -#[test] -fn test_payload_destructure() { - let payload = "00000019000002477c0c425640aa751f2cf2059ed4220f681dd96447588d5d0e69115791ffa122780935516ed1f4a25579f88699b2c5d17be12ea9cf88052808b57410a821aed4040086"; - let (message, signature, nonce, energy) = destructure_payload(payload); - assert_eq!(message, "0000001900000247"); - assert_eq!(signature, "7c0c425640aa751f2cf2059ed4220f681dd96447588d5d0e69115791ffa122780935516ed1f4a25579f88699b2c5d17be12ea9cf88052808b57410a821aed404"); - assert_eq!(nonce, 25); - assert_ne!(energy, 100); -} \ No newline at end of file diff --git a/lib/tests/protocol.rs b/lib/tests/protocol.rs new file mode 100644 index 0000000..d975df1 --- /dev/null +++ b/lib/tests/protocol.rs @@ -0,0 +1,122 @@ +use alloy_primitives::{Address, B256, U256}; +use ed25519_dalek::{Signer, SigningKey}; +use m3tering_protocol::{transition::*, SignedAction}; +use std::collections::BTreeMap; +fn action(k: &SigningKey, n: u32, mwh: u32) -> SignedAction { + let b = [n.to_be_bytes(), mwh.to_be_bytes()].concat(); + SignedAction { + message: hex::encode(&b), + signature: hex::encode(k.sign(&b).to_bytes()), + } +} +fn fixture() -> Input { + let k = SigningKey::from_bytes(&[42; 32]); + let pk = B256::from(k.verifying_key().to_bytes()); + Input { + chain_id: U256::from(1), + rollup: Address::repeat_byte(0x11), + words: BTreeMap::from([ + (0, U256::from(10) | (U256::from(1) << 40)), + (1, U256::ZERO), + (1000, U256::ZERO), + ]), + keys: BTreeMap::from([(0, [pk; 4]), (1, [pk; 4]), (1000, [pk; 4])]), + actions: BTreeMap::from([ + (0, vec![action(&k, 2, 25_600), action(&k, 3, 5_600)]), + (3, vec![action(&k, 1, 99_999)]), + (4, vec![action(&k, 1, 600)]), + (4000, vec![action(&k, 1, 7000)]), + ]), + } +} +#[test] +fn end_to_end_and_export() { + let i = fixture(); + let t = execute(&i).unwrap(); + assert_eq!(t.updates.len(), 3); + assert_eq!(unpack(t.updates[0].new, 0), (41, 3)); + assert_eq!(unpack(t.updates[0].new, 3), (99, 1)); + assert_eq!(unpack(t.updates[1].new, 0), (0, 1)); + assert_eq!(t.public_values.as_slice().len(), 32); + if let Ok(dir) = std::env::var("FIXTURE_DIR") { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + format!("{dir}/input.json"), + serde_json::to_vec_pretty(&i).unwrap(), + ) + .unwrap(); + std::fs::write( + format!("{dir}/transition.json"), + serde_json::to_vec_pretty(&t).unwrap(), + ) + .unwrap(); + std::fs::write(format!("{dir}/encoding.json"),serde_json::to_vec_pretty(&serde_json::json!({"commitment":t.public_values,"payload":format!("0x{}",hex::encode(&t.payload)),"oldWord":format!("{:#066x}",t.updates[0].old),"keyHash":t.updates[0].keys_hash})).unwrap()).unwrap(); + } +} +#[test] +fn old_words_keys_and_domain_are_bound() { + let i = fixture(); + let t = execute(&i).unwrap(); + let mut j = i.clone(); + j.chain_id += U256::from(1); + assert_ne!(statement(&j, &t.updates), t.public_values); + j = i.clone(); + j.rollup = Address::ZERO; + assert_ne!(statement(&j, &t.updates), t.public_values); + let mut u = t.updates.clone(); + u[0].old += U256::from(1); + assert_ne!(statement(&i, &u), t.public_values); + u = t.updates.clone(); + u[0].keys_hash = B256::ZERO; + assert_ne!(statement(&i, &u), t.public_values); +} +#[test] +fn grouping_and_nonce_only_update() { + let mut i = fixture(); + i.actions.retain(|m, _| *m == 0 || *m == 3); + assert_eq!(execute(&i).unwrap().updates.len(), 1); + i = fixture(); + i.actions.retain(|m, _| *m == 4); + let t = execute(&i).unwrap(); + assert_eq!(unpack(t.updates[0].new, 0), (0, 1)); +} + +#[test] +fn rejects_id_beyond_slot_capacity() { + let mut i = fixture(); + let actions = i.actions.remove(&0).unwrap(); + i.actions.insert((MAX_SLOT + 1) * 4, actions); + assert!(execute(&i).unwrap_err().contains("uint32")); +} + +#[test] +fn all_transition_fields_are_bound() { + let i = fixture(); + let t = execute(&i).unwrap(); + for field in 0..3 { + let mut changed = t.updates.clone(); + match field { + 0 => changed[0].slot += 1, + 1 => changed[0].new += U256::from(1), + _ => { + changed.pop(); + } + } + assert_ne!(statement(&i, &changed), t.public_values); + } +} + +#[test] +fn four_byte_slot_boundary_is_preserved() { + for slot in [0x01020304_u64, MAX_SLOT] { + let update = Update { + slot, + old: U256::ZERO, + new: U256::from(1), + keys_hash: B256::ZERO, + }; + let payload = encode(&[update]); + assert_eq!(payload.len(), 36); + assert_eq!(&payload[..4], &(slot as u32).to_be_bytes()); + } +} diff --git a/node/Cargo.toml b/node/Cargo.toml deleted file mode 100644 index 285ceee..0000000 --- a/node/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "node" -version = "0.1.0" -edition = "2024" - -[dependencies] -sp1-sdk = { version = "6.3.1", default-features = false, features = ["network"] } -axum = "0.8.4" -eyre = "0.6.8" -diesel = { version = "2", features = ["postgres", "r2d2", "serde_json"] } -serde = { workspace = true } -alloy-sol-types = { workspace = true } -alloy-primitives = { workspace = true } -tokio = { workspace = true } -serde_json = { workspace = true } -energy-tracker-lib = { path = "../lib" } -energy-tracker-verifier = { path = "../contracts" } -dotenvy = "0.15.7" - -[build-dependencies] -sp1-build = "6.0.0" diff --git a/node/build.rs b/node/build.rs deleted file mode 100644 index 8297542..0000000 --- a/node/build.rs +++ /dev/null @@ -1,10 +0,0 @@ -use sp1_build::build_program_with_args; - -fn main() { - // let args = BuildArgs { - // docker: true, - // elf_name: Some("energy-tracker-program".to_string()), - // ..Default::default() - // }; - build_program_with_args("../program", Default::default()) -} \ No newline at end of file diff --git a/node/src/lib.rs b/node/src/lib.rs deleted file mode 100644 index 0e53c16..0000000 --- a/node/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::env; - -use diesel::{ - PgConnection, r2d2::{self, ConnectionManager, PooledConnection}, table, update, -}; -use energy_tracker_lib::decode_slice; - -type DbPool = r2d2::Pool>; - - -table! { - m3ter_payloads (id) { - id -> Int4, - m3ter_id -> Int8, - message -> VarChar, - signature -> VarChar, - nonce -> Int8, - energy -> Int8, - is_verified -> Bool, - } -} - -pub fn establish_db_connection() -> DbPool { - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL not set in .env"); - let manager = ConnectionManager::::new(&database_url); - r2d2::Pool::builder() - .build(manager) - .expect("Failed to create pool.") -} - -pub async fn update_payload( - connection: &mut PooledConnection>, - nonces: Vec, -) { - use self::m3ter_payloads::dsl::*; - use diesel::prelude::*; - - let nonces_in_db = m3ter_payloads - .select(m3ter_id) - .distinct() - .load::(connection) - .unwrap(); - for m3ter in nonces_in_db { - let n = m3ter as usize; - let start = n * 6; - let end = n * 6 + 6; - let nonce_value = decode_slice(&nonces[start..end].try_into().unwrap()) as i64; - - let rows_deleted = update(m3ter_payloads) - .filter(m3ter_id.eq(m3ter)) - .filter(nonce.le(3362i64)) - .set(is_verified.eq(true)) - .execute(connection) - .unwrap(); - - println!("rows deleted {}", rows_deleted); - assert!(nonce_value > 100) - } - // println!("nonces in db {:?}", nonces_in_db) -} -// // update payloads -// let _ = diesel::update( -// m3ter_payloads.filter(m3ter_id.eq(i as i64).and(nonce.le(nonce_filter))), -// ) -// .set(is_verified.eq(true)) -// .execute(connection) -// .expect("Failed to update payloads"); - -// // delete payloads -// let _ = diesel::delete( -// m3ter_payloads.filter(m3ter_id.eq(i as i64).and(nonce.eq(nonce_filter))), -// ) -// .execute(connection) -// .expect("Failed to delete payloads"); diff --git a/node/src/main.rs b/node/src/main.rs deleted file mode 100644 index 36bc8c9..0000000 --- a/node/src/main.rs +++ /dev/null @@ -1,602 +0,0 @@ -use std::{collections::HashMap, env, format, println, sync::Arc}; - -use alloy_primitives::{B256, Bytes, U256, hex}; -use axum::{ - Router, - extract::{Query, State}, - http::StatusCode, - response::Json, - routing::{get, post}, -}; -use diesel::{ - PgConnection, RunQueryDsl, - prelude::{Insertable, Queryable, QueryableByName}, - r2d2::{self, ConnectionManager, PooledConnection}, - sql_query, table, -}; - -use energy_tracker_lib::{ - Payload, ProofStruct, PublicValuesStruct, calc_slot_key, decode_slice, destructure_payload, - extract_nonce, to_b256, -}; -use energy_tracker_verifier::{ - Provider, check_gas_balance, check_program_vkey, commit_state, get_block_rpl_bytes, - get_previous_values, get_provider, get_storage_proofs, -}; -use eyre::Result; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sp1_sdk::{ - Elf, HashableKey, ProveRequest, Prover, ProverClient, ProvingKey, SP1ProofWithPublicValues, - SP1Stdin, SP1VerifyingKey, include_elf, - network::{NetworkMode, signer::NetworkSigner}, - utils::setup_logger, -}; -use tokio::time::{self, Duration}; - -/// The ELF (executable and linkable format) file for the Succinct RISC-V zkVM. -pub const ENERGY_TRACKER_ELF: Elf = include_elf!("energy-tracker-program"); - -#[derive(Debug, Clone, Serialize, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProofFixture { - previous_balances: B256, - previous_nonces: B256, - new_balances: Bytes, - new_nonces: Bytes, - block_hash: B256, - vkey: String, - public_values: String, - proof: Bytes, -} - -type DbPool = r2d2::Pool>; - -#[derive(Queryable, QueryableByName, Insertable, Serialize, Debug)] -struct M3terPayload { - id: i32, - m3ter_id: i64, - message: String, - signature: String, - nonce: i64, - energy: i64, - is_verified: bool, -} - -#[derive(Insertable, Deserialize)] -#[diesel(table_name = m3ter_payloads)] -struct NewM3terPayload { - m3ter_id: i64, - message: String, - signature: String, - nonce: i64, - energy: i64, - is_verified: bool, -} - -table! { - m3ter_payloads (id) { - id -> Int4, - m3ter_id -> Int8, - message -> VarChar, - signature -> VarChar, - nonce -> Int8, - energy -> Int8, - is_verified -> Bool, - } -} - -fn get_query_string() -> String { - let query_string = "SELECT * - FROM m3ter_payloads - WHERE is_verified = FALSE - AND m3ter_id <> 7 - ORDER BY m3ter_id ASC, nonce ASC - "; - let limit = env::var("QUERY_LIMIT").unwrap_or_else(|_| "".to_string()); - let limit = if !limit.is_empty() { - println!("limit value {}", limit); - match limit.parse::() { - Ok(l) => format!("{} LIMIT {}", query_string, l), - Err(_) => query_string.to_string(), - } - } else { - query_string.to_string() - }; - println!("limit query {}", limit); - limit -} - -#[tokio::main] -async fn main() { - dotenvy::dotenv().ok(); - sp1_sdk::utils::setup_logger(); - // Define a simple route - println!("connecting to database..."); - let db_pool = establish_db_connection(); - let db_state = Arc::new(db_pool.clone()); - println!("connected to database"); - - tokio::spawn(async move { - let duration = env::var("BLOCK_INTERVAL") - .unwrap_or_else(|_| String::from("10000")) - .parse::() - .unwrap_or(10000); - let mut interval = time::interval(Duration::from_secs(duration)); - loop { - interval.tick().await; - match db_pool.get() { - Ok(mut conn) => { - _ = update_payload(&mut conn).await; - let proving_payload = sql_query(get_query_string()) - .load::(&mut conn) - .expect("Failed to load payloads"); - if proving_payload.is_empty() { - println!("No new payloads to process"); - continue; - } - let mut grouped: HashMap> = - HashMap::new(); - for payload in &proving_payload { - grouped - .entry(payload.m3ter_id.to_string()) - .or_default() - .push(energy_tracker_lib::M3terPayload::new( - payload.message.clone(), - payload.signature.clone(), - payload.nonce as u64, - payload.energy as u64, - )); - } - for (k, v) in &grouped { - println!("m3ter {}, with payload length {}", k, v.len()); - } - println!("========start running prover============="); - let (_, hash) = match run_prover(grouped, "groth16").await { - Ok(res) => res, - Err(e) => { - eprintln!("Prover error: {}", e); - return; - } - }; - println!("Committed state with tx hash: {}", hash); - let _ = update_payload(&mut conn).await; - } - Err(e) => { - eprintln!("Failed to get DB connection: {:?}", e); - break; - } - } - } - }); - - let port = env::var("PROVER_NODE_PORT").unwrap_or_else(|_| "8080".to_string()); - let addr = format!("0.0.0.0:{}", port); - - let app = Router::new() - .route("/", get(root)) - .route("/batch-payloads", post(batch_payload_handler)) - .route("/health", get(health)) - .route("/run_prover", get(run_prover_handler)) - .route("/vkey", get(get_prover_vkey)) - .route( - "/update_verified_payloads", - get(update_verified_payloads_handler), - ) - .with_state(db_state); - - println!("Starting server on http://localhost:{}", port); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve::serve(listener, app) - .await - .expect("server should start"); -} - -fn establish_db_connection() -> DbPool { - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL not set in .env"); - let manager = ConnectionManager::::new(&database_url); - r2d2::Pool::builder() - .build(manager) - .expect("Failed to create pool.") -} - -// Handler function -async fn root() -> Json { - Json(json!({ "message": "Hello, world!" })) -} - -async fn health(State(db_state): State>) -> Json { - let connection = db_state.get().is_ok(); - let code = if connection { 200 } else { 500 }; - Json(json!({ "code": code, "success": code == 200 })) -} - -async fn run_prover_handler( - State(db_state): State>, - Query(params): Query>, -) -> (StatusCode, Json) { - let proof_type = params - .get("proof_type") - .map(|s| { - if s != "plonk" && s != "groth16" { - "groth16".to_string() - } else { - s.clone() - } - }) - .unwrap_or("groth16".to_string()); - - let mut conn = match db_state.get() { - Ok(state) => state, - Err(e) => { - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ "error": true, "message": format!("encountered error {:?}", e) })), - ); - } - }; - - tokio::spawn(async move { - - let _ = update_payload(&mut conn).await; - let proving_payload = match sql_query(get_query_string()).load::(&mut conn) { - Ok(p) => p, - Err(e) => { - eprintln!("Failed to load payloads: {:?}", e); - return; - } - }; - - if proving_payload.is_empty() { - println!("No payloads to process"); - return; - } - - let mut grouped: HashMap> = HashMap::new(); - for payload in &proving_payload { - grouped - .entry(payload.m3ter_id.to_string()) - .or_default() - .push(energy_tracker_lib::M3terPayload::new( - payload.message.clone(), - payload.signature.clone(), - payload.nonce as u64, - payload.energy as u64, - )); - } - - let (_, hash) = match run_prover(grouped, &proof_type).await { - Ok(res) => res, - Err(e) => { - eprintln!("Prover error: {}", e); - return; - } - }; - println!("Committed state with tx hash: {}", hash); - let _ = update_payload(&mut conn).await; - println!("Updated payloads as verified"); - }); - - ( - StatusCode::OK, - Json(json!({ - "code": 200, - "message": "Prove generation started..." - })), - ) -} - -async fn get_prover_vkey() -> Json { - let private_key = env::var("PRIVATE_KEY").expect("PRIVATE_KEY not set in .env"); - let prover = ProverClient::builder() - .network_for(NetworkMode::Mainnet) - .private_key(&private_key) - .build() - .await; - let pk = prover.setup(ENERGY_TRACKER_ELF).await.unwrap(); - Json(json!({ - "vkey": pk.verifying_key().bytes32() - })) -} - -async fn batch_payload_handler( - State(db_state): State>, - Json(payloads): Json>, -) -> (StatusCode, Json) { - struct PayloadItem { - pub m3ter_id: i64, - pub message: String, - } - - impl From for PayloadItem { - fn from(value: Value) -> Self { - Self { - m3ter_id: value["m3ter_id"].as_i64().unwrap(), - message: String::from(value["message"].as_str().unwrap_or("")), - } - } - } - let mut connection = db_state.get().unwrap(); - let received_count = payloads.len(); - - let new_payloads = payloads - .into_iter() - .map(PayloadItem::from) - .filter(|item| { - is_unique_nonce(&mut connection, item.m3ter_id, extract_nonce(&item.message)) - }) - .map(|payload| { - let m3ter_id = payload.m3ter_id; - let (message, signature, nonce, energy) = destructure_payload(&payload.message); - NewM3terPayload { - m3ter_id, - message: message.to_string(), - signature: signature.to_string(), - nonce: nonce as i64, - energy: energy as i64, - is_verified: false, - } - }) - .collect::>(); - - println!("Inserting payload"); - let inserted: Vec = diesel::insert_into(m3ter_payloads::table) - .values(&new_payloads) - .get_results(&mut connection) - .expect("Failed to insert payload"); - - println!("Inserted payload: {:?}", inserted); - ( - StatusCode::OK, - Json( - json!({ "inserted": inserted, "nonces_inserted": inserted.len(), "nonces_repeated": received_count - inserted.len() }), - ), - ) -} - -async fn update_verified_payloads_handler( - State(db_state): State>, -) -> (StatusCode, Json) { - let mut connection = db_state.get().unwrap(); - let _ = update_payload(&mut connection).await; - - ( - StatusCode::OK, - Json(json!({ - "code": 200, - "success": true - })), - ) -} - -async fn run_prover( - payload: HashMap>, - proof_type: &str, -) -> Result<(ProofFixture, String)> { - setup_logger(); - let provider = get_provider().await.unwrap(); - let private_key = env::var("PRIVATE_KEY").expect("PRIVATE_KEY not set in .env"); - let signer = NetworkSigner::local(&private_key).unwrap(); - if !check_gas_balance(&provider).await.unwrap() { - println!("Insufficient gas balance to proceed with proving."); - return Err(eyre::eyre!("Insufficient gas balance")); - } - println!("=============setting up prover client==============="); - let prover_client = ProverClient::builder() - // .cpu() - .network_for(NetworkMode::Mainnet) - .signer(signer) - .build() - .await; - - let pk = prover_client.setup(ENERGY_TRACKER_ELF).await.unwrap(); - let vk = pk.verifying_key(); - println!("=============proceeding to build program inputs==============="); - let (payload, _) = match build_proving_payload(&provider, payload, vk).await { - Ok(res) => res, - Err(e) => return Err(eyre::eyre!("Failed to build payload: {:?}", e)), - }; - - let mut stdin = SP1Stdin::new(); - stdin.write(&payload); - // println!("====starting execution report======"); - // let (_, report) = prover_client - // .execute(ENERGY_TRACKER_ELF, stdin.clone()) - // .await - // .unwrap(); - // println!("report {:?}", report); - println!("====starting proof generation======"); - let proof = match match proof_type { - "plonk" => { - prover_client - .prove(&pk, stdin) - .skip_simulation(true) - .plonk() - .await - } - "groth16" => { - prover_client - .prove(&pk, stdin) - .skip_simulation(true) - .groth16() - .await - } - _ => panic!("Unsupported proof type: {}", proof_type), - } { - Ok(proof) => proof, - Err(e) => return Err(eyre::eyre!("Prover error: {:?}", e)), - }; - - let (proof_fixture, err) = create_proof_fixture(&proof, vk); - if err.is_some() { - return Err(eyre::eyre!("Failed to create proof fixture: {:?}", err)); - } - println!("Proof generated successfully proof = {:?}", &proof_fixture); - - println!("Committing state ..."); - let hash = match commit_state( - &provider, - &proof_fixture.new_balances, - &proof_fixture.new_nonces, - &proof_fixture.proof, - ) - .await - { - Ok(tx_hash) => tx_hash, - Err(e) => return Err(eyre::eyre!("Failed to commit state: {:?}", e)), - }; - // unimplemented!("go back") - Ok((proof_fixture, hash.to_string())) -} - -async fn build_proving_payload( - provider: &impl Provider, - payload: HashMap>, - vk: &SP1VerifyingKey, -) -> Result<(Payload, B256)> { - let previous_nonces = get_previous_values(&provider, U256::from(1)).await.unwrap(); - let previous_balances = get_previous_values(&provider, U256::from(0)).await.unwrap(); - - let previous_nonces = if previous_nonces.len() > 2 { previous_nonces } else { Bytes::new() }; - let previous_balances = if previous_balances.len() > 2 { previous_balances } else { Bytes::new() }; - - let slot_keys = payload - .keys() - .map(|key| { - let m3ter_id: u64 = key.parse().expect("meter id not valid"); - m3ter_id - }) - .map(|m3ter_id| to_b256(calc_slot_key(U256::from(m3ter_id)).unwrap())) - .collect(); - - let (account_proof, encoded_account, storage_hash, proofs, anchor_block) = - get_storage_proofs(&provider, slot_keys).await.unwrap(); - let block_bytes = get_block_rpl_bytes(&provider, anchor_block).await.unwrap(); - if !check_program_vkey(&provider, vk.bytes32_raw()) - .await - .unwrap() - { - return Err(eyre::eyre!( - "Program Vkey does not match the on-chain value" - )); - } - println!("Loaded payloads: {:?}", payload); - println!("Anchor Block: {:?}", anchor_block); - Ok(( - Payload { - mempool: payload, - previous_nonces: previous_nonces.into(), - previous_balances: previous_balances.into(), - proofs: ProofStruct { - account_proof, - encoded_account, - storage_hash, - proofs, - }, - block_bytes, - }, - anchor_block, - )) -} - -async fn update_payload( - connection: &mut PooledConnection>, -) -> Result<()> { - use self::m3ter_payloads::dsl::*; - use diesel::prelude::*; - #[derive(Debug)] - enum DataStrategy { - Persist, - Delete, - } - let strategy: DataStrategy = match env::var("DATA_STRATEGY").unwrap().as_str() { - "persist" => DataStrategy::Persist, - "delete" => DataStrategy::Delete, - _ => DataStrategy::Persist, - }; - let provider = get_provider().await.expect("Failed to get provider"); - let nonces = get_previous_values(&provider, U256::from(1)).await.unwrap(); - let nonces_in_db = m3ter_payloads - .select(m3ter_id) - .distinct() - .load::(connection)?; - nonces_in_db.iter().for_each(|m3ter| { - let n = *m3ter as usize; - let start = n * 6; - let end = n * 6 + 6; - let nonce_value = decode_slice(&nonces[start..end].try_into().unwrap()) as i64; - let rows = match strategy { - DataStrategy::Persist => diesel::update(m3ter_payloads) - .filter(m3ter_id.eq(m3ter)) - .filter(nonce.le(nonce_value)) - .set(is_verified.eq(true)) - .execute(connection), - DataStrategy::Delete => diesel::delete(m3ter_payloads) - .filter(m3ter_id.eq(m3ter)) - .filter(nonce.le(nonce_value)) - .execute(connection), - }; - - println!( - "rows updated {} with strategy {:?}", - rows.unwrap_or_default(), - strategy - ); - }); - Ok(()) -} - -fn is_unique_nonce( - connection: &mut PooledConnection>, - i_m3ter_id: i64, - i_nonce: i64, -) -> bool { - use self::m3ter_payloads::dsl::*; - use diesel::prelude::*; - - match m3ter_payloads - .filter(m3ter_id.eq(i_m3ter_id).and(nonce.eq(i_nonce))) - .first::(connection) - { - Ok(_) => { - println!( - "Nonce {} for m3ter {} already exists in the database", - i_nonce, i_m3ter_id - ); - false - } - Err(_) => { - println!("Nonce {} for m3ter {} is unique", i_nonce, i_m3ter_id); - true - } - } -} - -fn create_proof_fixture(proof: &SP1ProofWithPublicValues, vk: &SP1VerifyingKey) -> (ProofFixture, Option) { - let bytes = proof.public_values.as_slice(); -if bytes.len() < 96 { - let s = String::from_utf8_lossy(bytes); - println!("Public values too short ({} bytes): {}", bytes.len(), s); - return (ProofFixture::default(), Some(format!("Public values too short ({} bytes): {}", bytes.len(), s))); -} - let output = PublicValuesStruct::from_bytes(bytes); - let PublicValuesStruct { - previous_balances, - previous_nonces, - new_balances, - new_nonces, - block_hash, - } = output; - - // Create the testing fixture so we can test things end-to-end. - (ProofFixture { - previous_balances, - previous_nonces, - new_balances, - new_nonces, - block_hash, - vkey: vk.bytes32().to_string(), - public_values: format!("0x{}", hex::encode(bytes)), - proof: proof.bytes().into(), - }, None) -} diff --git a/program/Cargo.toml b/program/Cargo.toml index de6ef61..8d11064 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -1,17 +1,7 @@ [package] +name = "m3tering-program" version = "0.1.0" -name = "energy-tracker-program" edition = "2021" - -[profile.release] -overflow-checks = true - [dependencies] -alloy-sol-types = { workspace = true } -serde = { workspace = true } -hex = { workspace = true } -sp1-zkvm = { version = "6.3.1" } -energy-tracker-lib = { path = "../lib" } -rayon = "1.11.0" - - +sp1-zkvm = "=6.3.1" +m3tering-protocol = { path = "../lib" } diff --git a/program/src/main.rs b/program/src/main.rs index 9693fb5..b5c0c06 100644 --- a/program/src/main.rs +++ b/program/src/main.rs @@ -1,155 +1,8 @@ #![no_main] sp1_zkvm::entrypoint!(main); -use std::println; - -use energy_tracker_lib::{ - calc_slot_key, get_state_root, to_b256, to_keccak_hash, to_u256, track_energy, trim_zeros, - verify_account_proof, M3ter, Payload, PublicValuesStruct, -}; - +use m3tering_protocol::transition::{execute, Input}; pub fn main() { - let payload = sp1_zkvm::io::read::(); - let address = "9C547B649475f1bE81323AefdbcF209C17961D5E"; - println!("===== starting progam execution ================"); - let mempool = payload.mempool; - let initial_nonces = payload.previous_nonces; - let initial_balances = payload.previous_balances; - println!("======= destructuring values ==============="); - let proof_struct = payload.proofs; - let (account_proof, encoded_account, storage_hash, proofs) = ( - proof_struct.account_proof, - proof_struct.encoded_account, - proof_struct.storage_hash, - proof_struct.proofs, - ); - - let (state_root, block_bytes) = (get_state_root(&payload.block_bytes), payload.block_bytes); - - println!("======= verify account ==============="); - if !verify_account_proof( - state_root, - hex::decode(address).unwrap(), - encoded_account, - account_proof, - ) { - sp1_zkvm::io::commit_slice("Account proof verification failed".as_bytes()); - return; - }; - - let m3ter_position = |m3ter_id: usize| (m3ter_id * 6, m3ter_id * 6 + 6); - let decode_slice = |data: &[u8; 6]| -> u64 { - // Convert 6 bytes to i64 (big-endian, pad with zeros) - let mut buf = [0u8; 8]; - buf[2..].copy_from_slice(data); // pad the first 2 bytes with zeros - u64::from_be_bytes(buf) - }; - - let encode_slice = |value: u64| -> ([u8; 6], bool) { - let bytes: [u8; 8] = value.to_be_bytes(); // [u8; 8] - if bytes[..2][0] + bytes[..2][1] > 0 { - return ([0; 6], false); - } - - let six_bytes = &bytes[2..8]; // Take the last 6 bytes (big-endian) - (six_bytes.try_into().unwrap(), true) - }; - - if initial_nonces.len() != initial_balances.len() { - let error_message = format!( - "Initial nonces and balances length mismatch: {} vs {}", - initial_nonces.len(), - initial_balances.len() - ); - sp1_zkvm::io::commit_slice(error_message.as_bytes()); - return; - } - let mut new_nonces = initial_nonces.clone(); - let mut new_balances = initial_balances.clone(); - - println!("======= process values ==============="); - for (m3ter_key, m3ter_payloads) in mempool { - let m3ter_id = m3ter_key.parse::().unwrap(); - let (public_key, proof) = match proofs.get(dbg!(&to_b256( - calc_slot_key(to_u256(m3ter_id as u64)).unwrap() - ))) { - Some(value) => value, - None => continue, - }; - let public_key = to_b256(*public_key).to_string(); - let m3ter = M3ter::new(&m3ter_key, &public_key); - - let (start, end) = m3ter_position(m3ter_id); - if start >= initial_nonces.len() || initial_nonces.len() < 6 { - let padding_len = end - initial_nonces.len(); - new_nonces.extend(vec![0u8; padding_len]); - new_balances.extend(vec![0u8; padding_len]); - } - - println!( - "Decoding previous values for M3ter ID: {}, nonce {}, balance {}", - m3ter_id, - hex::encode(&new_nonces[start..end]), - hex::encode(&new_balances[start..end]) - ); - let current_nonce = decode_slice(&new_nonces[start..end].try_into().unwrap()); - let current_balance = decode_slice(&new_balances[start..end].try_into().unwrap()); - println!( - "Decoded values = Current Nonce: {}, Current Balance: {}", - current_nonce, current_balance - ); - let (energy_sum, latest_nonce) = track_energy( - m3ter, - &m3ter_payloads, - current_nonce, - (&storage_hash, proof), - ); - println!( - "Values after tracking = Energy Sum: {}, Latest Nonce: {}", - energy_sum, latest_nonce - ); - - let energy_sum = energy_sum + current_balance; - - let (nonce_encoded, nonce_status) = encode_slice(latest_nonce); - let (balance_encoded, status) = encode_slice(energy_sum); - if !nonce_status || !status { - println!( - "Nonce or balance exceeds the 6-byte limit for m3ter ID: {}", - m3ter_id - ); - continue; - } - println!( - "Encoded values = Nonce: {}, Balance: {}", - &hex::encode(nonce_encoded), - &hex::encode(balance_encoded) - ); - - new_nonces[start..end].copy_from_slice(&nonce_encoded); - new_balances[start..end].copy_from_slice(&balance_encoded); - - println!( - "M3ter ID: {}, Energy Sum: {}, Latest Nonce: {}", - m3ter_id, energy_sum, latest_nonce - ); - } - - let new_balances = trim_zeros(new_balances); - - if new_balances == initial_balances { - sp1_zkvm::io::commit_slice("New balances matches previous balances".as_bytes()); - return; - } - - let new_nonces = trim_zeros(new_nonces).into(); - let new_balances = new_balances.into(); - - let public_values = PublicValuesStruct { - block_hash: to_keccak_hash(block_bytes), - previous_balances: to_keccak_hash(initial_balances), - previous_nonces: to_keccak_hash(initial_nonces), - new_balances, - new_nonces, - }; - sp1_zkvm::io::commit_slice(&public_values.concat_bytes()); + let input = sp1_zkvm::io::read::(); + let transition = execute(&input).expect("invalid state transition witness"); + sp1_zkvm::io::commit_slice(transition.public_values.as_slice()); } diff --git a/rust-toolchain b/rust-toolchain deleted file mode 100644 index 5845576..0000000 --- a/rust-toolchain +++ /dev/null @@ -1,3 +0,0 @@ -[toolchain] -channel = "stable" -components = ["llvm-tools", "rustc-dev"] \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/streamr-client/.env.example b/streamr-client/.env.example index f292e14..3e5f314 100644 --- a/streamr-client/.env.example +++ b/streamr-client/.env.example @@ -1,4 +1,3 @@ - -PRIVATE_KEY= -STREAM_ID=0x567853282663b601bfdb9203819b1fbb3fe18926/m3tering/test -STREAMR_ENV=live \ No newline at end of file +DATABASE_URL=postgres://user:password@localhost/m3tering +STREAM_ID=your-stream-id +STREAMR_CLIENT_PORT=3000 diff --git a/streamr-client/index.js b/streamr-client/index.js index cca07f4..becf749 100644 --- a/streamr-client/index.js +++ b/streamr-client/index.js @@ -32,7 +32,7 @@ initializeDatabase().then(async db => { const app = express() const port = process.env.STREAMR_CLIENT_PORT || 3000 - app.get('/health', res => { + app.get('/health', (_req, res) => { res.status(200).json({ status: 'ok' }) }) diff --git a/tests/data_test.rs b/tests/data_test.rs deleted file mode 100644 index 7128702..0000000 --- a/tests/data_test.rs +++ /dev/null @@ -1,5 +0,0 @@ - -#[test] -fn test_data_connection() { - -} \ No newline at end of file From ea8002d981e882694f0db1094f62b0c4a10b4d6f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 27 Oct 2026 10:35:49 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Package=20the=20prover=20with=20validated=20ingestion=20and?= =?UTF-8?q?=20scheduled=20proving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Docker Compose, locked image inputs, HTTPS/Streamr validation, persistent queueing, gas admission and durable request/transaction recovery. Publish tested images through GitHub Actions for simple installations. Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- .dockerignore | 10 +- .env.example | 38 +- .github/workflows/images.yml | 101 ++ .github/workflows/test.yml | 25 + .gitignore | 3 + Cargo.toml | 10 + Dockerfile | 35 + README.md | 80 +- compose.yaml | 95 ++ host/src/main.rs | 448 +++++-- node | 17 + ops/Dockerfile.release | 10 + ops/deploy-rollup.cjs | 48 + ops/setup.js | 26 + ops/test-prover.nginx.conf | 15 + release/program-build.json | 12 + release/program.elf | Bin 0 -> 164808 bytes release/program.sha256 | 1 + rust-toolchain.toml | 2 +- streamr-client/Dockerfile | 25 - streamr-client/db/migrations/initial_01.sql | 8 + streamr-client/db/save-gossip-message.js | 44 - streamr-client/index.js | 132 +- streamr-client/package-lock.json | 1205 +++++++++++++------ streamr-client/package.json | 13 +- streamr-client/test/database.test.js | 22 + streamr-client/test/validation.test.js | 64 + streamr-client/util/decode.js | 26 - streamr-client/util/streamr-client.js | 24 - streamr-client/validation.js | 96 ++ streamr-client/worker-main.js | 15 + streamr-client/worker.js | 108 ++ 32 files changed, 2088 insertions(+), 670 deletions(-) create mode 100644 .github/workflows/images.yml create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100755 node create mode 100644 ops/Dockerfile.release create mode 100644 ops/deploy-rollup.cjs create mode 100644 ops/setup.js create mode 100644 ops/test-prover.nginx.conf create mode 100644 release/program-build.json create mode 100755 release/program.elf create mode 100644 release/program.sha256 delete mode 100644 streamr-client/Dockerfile create mode 100644 streamr-client/db/migrations/initial_01.sql delete mode 100644 streamr-client/db/save-gossip-message.js create mode 100644 streamr-client/test/database.test.js create mode 100644 streamr-client/test/validation.test.js delete mode 100644 streamr-client/util/decode.js delete mode 100644 streamr-client/util/streamr-client.js create mode 100644 streamr-client/validation.js create mode 100644 streamr-client/worker-main.js create mode 100644 streamr-client/worker.js diff --git a/.dockerignore b/.dockerignore index eb507bc..950bd36 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,10 @@ .git -target -elf -**/node_modules **/.env +**/.env.* +!**/.env.example +**/node_modules +**/target +elf prover-output* +*.log +release/m3tering-prover diff --git a/.env.example b/.env.example index 07f75b0..fb219aa 100644 --- a/.env.example +++ b/.env.example @@ -1,29 +1,39 @@ -# Read-only plan. Hash-pinned eth_call (EIP-1898) is required. -RPC_URL=https://your-ethereum-rpc -ROLLUP_ADDRESS=0xYOUR_ROLLUP_DEPLOYMENT -# Choose ONE input source. Same PostgreSQL schema as existing node. -DATABASE_URL=postgres://user:password@localhost/m3tering -# ACTIONS_FILE=actions.json -OUTPUT_DIR=prover-output-batch-0 +# Prefer ./node setup to create a private .env interactively. +COMPOSE_PROJECT_NAME=m3tering +POSTGRES_PASSWORD=replace-with-a-random-long-password +CHAIN_ID=11155111 +RPC_URL=https://your-sepolia-rpc +ROLLUP_ADDRESS=0xAb683B638f634F0a56e83acAcB81D17900606A79 +HTTP_PORT=19000 +PRIVATE_KEY= +SP1_PRIVATE_KEY= +STREAM_ID= +# Optional separate identity when the Streamr subscription needs authentication. +STREAMR_PRIVATE_KEY= +PROVING_ENABLED=false +PROVE_INTERVAL_SECONDS=3600 MAX_ACTIONS=10000 MAX_SLOTS=4096 -# Explicit modeled schedule; does not activate a fork in the connected node. +MAX_BATCH_ACTIONS=1000 GAS_SCHEDULE=current EXECUTION_GAS_BUDGET=15000000 TOTAL_GAS_BUDGET=15000000 FIXED_EXECUTION_ALLOWANCE=1500000 PER_WORD_OVERHEAD_ALLOWANCE=12000 -# Only required for the explicit run command. Never commit real credentials. -PRIVATE_KEY= MAX_FEE_PER_GAS_WEI=30000000000 MAX_PRIORITY_FEE_PER_GAS_WEI=1000000000 ETH_RESERVE_WEI=1000000000000000 -SP1_PRIVATE_KEY= -SP1_ELF=elf/m3tering-program MAX_PROVER_CYCLES=100000000 MAX_PROVER_PGU=100000000 -# Required operator price cap, PROVE wei per PGU; must match auction tick size. -MAX_PRICE_PER_PGU= +# auto = live SP1 quote; numeric = PROVE wei per PGU (must match auction tick). +MAX_PRICE_PER_PGU=auto +# Optional hard per-proof cap in PROVE wei, excluding retained credit reserve. +MAX_PROOF_COST_WEI= SP1_CREDIT_RESERVE_WEI=0 PROOF_TIMEOUT_SECONDS=3600 CONFIRMATIONS=2 +# Standalone CLI only (Compose sets DATABASE_URL and SP1_ELF for you): +# DATABASE_URL=postgres://user:password@localhost/m3tering +# ACTIONS_FILE=actions.json +# OUTPUT_DIR=prover-output-batch-0 +# SP1_ELF=release/program.elf diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..72fef9e --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,101 @@ +name: Publish prover images +on: + push: + branches: [propter-polar-bear] + tags: ['prover-v*'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: prover-images-${{ github.ref }} + cancel-in-progress: false +jobs: + tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16-bookworm@sha256:d81f7b51697014367236d07ec58b54153fc28f828b72bf247fe5c7487520bcb0 + env: {POSTGRES_USER: m3tering, POSTGRES_PASSWORD: validation-only, POSTGRES_DB: m3tering} + ports: ['5432:5432'] + options: --health-cmd "pg_isready -U m3tering" --health-interval 5s --health-retries 10 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@1.99.1 + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: '24.16.0' + cache: npm + cache-dependency-path: streamr-client/package-lock.json + - run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev protobuf-compiler + - run: cargo fmt --all -- --check + - run: cargo test --locked -p m3tering-protocol -p m3tering-prover + - run: npm ci --prefix streamr-client + - run: npm test --prefix streamr-client + env: + TEST_DATABASE_URL: postgres://m3tering:validation-only@localhost:5432/m3tering + - run: sha256sum -c release/program.sha256 + images: + needs: tests + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - target: api + suffix: '-api' + - target: worker + suffix: '' + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/m3tering/prover + tags: | + type=ref,event=branch,suffix=${{ matrix.suffix }} + type=ref,event=tag,suffix=${{ matrix.suffix }} + type=sha,format=long,suffix=${{ matrix.suffix }} + flavor: latest=false + - uses: docker/build-push-action@v6 + with: + context: . + target: ${{ matrix.target }} + platforms: linux/amd64 + load: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }} + - name: Smoke-test the built image before publication + env: + IMAGE_TAGS: ${{ steps.meta.outputs.tags }} + TARGET: ${{ matrix.target }} + run: | + image=$(printf '%s\n' "$IMAGE_TAGS" | head -1) + if [ "$TARGET" = worker ]; then + docker run --rm --memory=4g -e WITNESS_FILE=/opt/m3tering/fixtures/input.json "$image" /usr/local/bin/m3tering-prover execute-fixture | tee execution.log + key=$(jq -r .program_key release/program-build.json) + grep -F "Program verification key: $key" execution.log + else + docker run --rm "$image" npm test + fi + - name: Publish tested images + env: + IMAGE_TAGS: ${{ steps.meta.outputs.tags }} + run: | + while IFS= read -r image; do + docker push "$image" + printf '%s\n' "$image" >> "$GITHUB_STEP_SUMMARY" + done <<< "$IMAGE_TAGS" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3240afc..6d58f12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,3 +26,28 @@ jobs: SP1_ELF: elf/m3tering-program WITNESS_FILE: fixtures/input.json run: cargo run --locked -p m3tering-prover --features proving -- execute-fixture + + ingestion: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-bookworm@sha256:d81f7b51697014367236d07ec58b54153fc28f828b72bf247fe5c7487520bcb0 + env: + POSTGRES_USER: m3tering + POSTGRES_PASSWORD: validation-only + POSTGRES_DB: m3tering + ports: ['5432:5432'] + options: --health-cmd "pg_isready -U m3tering" --health-interval 5s --health-retries 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: '24.16.0' + cache: npm + cache-dependency-path: streamr-client/package-lock.json + - run: npm ci --prefix streamr-client + - run: npm test --prefix streamr-client + env: + TEST_DATABASE_URL: postgres://m3tering:validation-only@localhost:5432/m3tering + - run: sha256sum -c release/program.sha256 + - run: docker build --target api . diff --git a/.gitignore b/.gitignore index e80e107..e8fa54d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ **/node_modules/ **/.env *.log + +backups/ +release/m3tering-prover diff --git a/Cargo.toml b/Cargo.toml index 8d2f6f9..a5489c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,13 @@ curve25519-dalek = { git = "https://github.com/sp1-patches/curve25519-dalek", ta k256 = { git = "https://github.com/sp1-patches/elliptic-curves", tag = "patch-k256-13.4-sp1-5.0.0" } p256 = { git = "https://github.com/sp1-patches/elliptic-curves", tag = "patch-p256-13.2-sp1-5.0.0" } ecdsa = { git = "https://github.com/sp1-patches/signatures", tag = "patch-16.9-sp1-4.1.0" } + +# Keep compilation feasible on small nodes. Execution/cryptography dependencies +# retain release optimization; the SDK is primarily network orchestration. +[profile.release.package.sp1-sdk] +opt-level = 0 +codegen-units = 256 + +[profile.release.package.m3tering-prover] +opt-level = 1 +codegen-units = 64 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..98f4248 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1.7 +ARG RUST_IMAGE=rust:1.99.1-bookworm@sha256:48b7f435b8fc6378ce43b44f15e3a47fd8919b2825f835c39bd4bd3da50af5b8 +ARG NODE_IMAGE=node:24.16.0-bookworm-slim@sha256:2c87ef9bd3c6a3bd4b472b4bec2ce9d16354b0c574f736c476489d09f560a203 +FROM ${RUST_IMAGE} AS builder +RUN apt-get update && apt-get install -y --no-install-recommends protobuf-compiler pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /src +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY lib lib +COPY host host +COPY program program +ENV CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 +RUN --mount=type=cache,target=/usr/local/cargo/registry --mount=type=cache,target=/usr/local/cargo/git cargo build --locked --release -p m3tering-prover --features proving +FROM ${NODE_IMAGE} AS dependencies +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ cmake ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY streamr-client/package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force +FROM ${NODE_IMAGE} AS api +RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=dependencies /app/node_modules /app/node_modules +COPY streamr-client /app +WORKDIR /app +USER node +EXPOSE 3000 +CMD ["node", "index.js"] + +FROM api AS worker +COPY --from=builder /src/target/release/m3tering-prover /usr/local/bin/m3tering-prover +COPY release /opt/m3tering/release +COPY fixtures /opt/m3tering/fixtures +WORKDIR /opt/m3tering +RUN sha256sum -c release/program.sha256 +ENV SP1_ELF=/opt/m3tering/release/program.elf BATCH_DIR=/data/batches SP1_CACHE_DIR=/data/cache +WORKDIR /app +CMD ["node", "worker-main.js"] diff --git a/README.md b/README.md index e920938..3bdfd41 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,62 @@ The prover validates signed meter actions, aggregates energy and produces SP1 proofs for sparse [Rollup](https://github.com/M3tering/Rollup) state updates. The host reads only selected storage words and signing keys, plans a batch that fits its gas budget, proves the transition, and submits the resulting absolute replacements. +## Run a node + +Install Docker Engine / Docker Desktop with Compose. From this repository: + +```sh +./node setup +./node start +./node status +``` + +Setup asks for the RPC URL, deployed Rollup address, Ethereum/SP1 credentials, optional Streamr stream, and proof interval. Secrets are saved in a private, git-ignored `.env`; do not share that file. The normal installation pulls images built by GitHub Actions; it does not compile Rust or SP1 locally. `./node update` pulls the newest published branch images. Pin `PROVER_IMAGE` and `API_IMAGE` to the release's `@sha256:...` digests for an exact deployment, rather than following a moving branch tag. + +The publishing workflow `.github/workflows/images.yml` runs protocol/ingestion tests, builds both Docker targets on GitHub-hosted runners, executes the real SP1 guest in the worker image, and publishes only after its checks pass. Images are `ghcr.io/m3tering/prover:propter-polar-bear` (worker) and `ghcr.io/m3tering/prover:propter-polar-bear-api` (API), with immutable commit-specific `sha-COMMIT` tags as well. Publishing uses GitHub's built-in token; it needs no Ethereum key or proving credits. It never buys a proof. Existing production `main` image tags are not overwritten by this workflow. + +To build from source instead, run `./node build`, then `./node start`. Source builds are large: prefer at least 8 GB RAM and 15 GB free disk space. The checked-in Dockerfile pins base-image digests; Rust 1.99.1, Cargo.lock, package-lock.json and SP1 6.3.1 pin application inputs. The guest ELF is checked in with its SHA-256 and program vkey manifest and verified during the image build. OS security packages are resolved at image build time; published image digests identify the exact runtime. Images target Linux amd64; Docker can emulate this on ARM machines, with a performance penalty. `WORKER_MEMORY` sets the worker memory limit (default 4 GB). + +Compose runs three persistent services: PostgreSQL, an HTTP/Streamr ingestion API, and the scheduled proving worker. The API does **not** receive the Ethereum/SP1 spending keys. PostgreSQL is internal; HTTP binds only to `127.0.0.1:19000`. Database and proof artifacts survive restarts and `docker compose down`; **do not use `down -v`** unless intentionally deleting all state. Each database is bound to one chain and Rollup address. + +For public HTTPS, point a hostname at your server and place nginx or another TLS proxy in front of port 19000. `ops/test-prover.nginx.conf` is the configuration for `test-prover.m3ter.ing`. Obtain a trusted certificate with Certbot. Do not expose a plaintext public port as a substitute for HTTPS. + +```sh +./node logs api # ingestion / Streamr status +./node logs worker # schedule and batch status +./node network # read SP1 credit balance and quoted price +./node backup # PostgreSQL backup under backups/ +./node stop # retains all data +``` + +Also back up the `proofs` Docker volume, which contains paid requests, proofs, signed transactions and receipts. Restore the database and proof files together. Inspect batch `host.log` files in that volume for detailed preflight failures. No public endpoint can trigger spending; the worker is controlled by private configuration. + +### Receiving actions + +Both transports accept the same JSON array used by the previous deployment: + +```json +[{"m3ter_id": 7, "message": "<144 hex characters: 8 signed bytes followed by 64 signature bytes>"}] +``` + +POST it to `https://YOUR_HOST/batch-payloads` with `Content-Type: application/json`, or publish it to `STREAM_ID`. The two signed uint32 fields are big-endian nonce and energy in mWh. The API checks exact byte lengths, meter/nonce capacity, Ed25519 signatures against the **current Rollup key**, and already-consumed nonces before storing anything. It derives all accounting fields from the signed bytes. The whole batch is atomic; an invalid action rejects it. Identical retries deduplicate, conflicting actions at the same meter nonce reject, and the database unique constraint also handles concurrent arrivals. HTTP returns 202 with accepted/duplicate counts, 400 for invalid data, 413 for oversized bodies, and 429/503 for retryable congestion or RPC failures. Default limits are 1,000 actions and 256 KiB per HTTP batch. Streamr failures are logged; publishers should retain/retry data or use HTTPS acknowledgements when delivery confirmation is needed. + +`GET /health` checks the API/database and reports chain, Rollup and Streamr status; it does not claim that a paid proof has succeeded. A fresh Rollup starts every account and nonce at zero. Meter owners must call `setPublicKey(id,key)`, then meters must start with nonce **1**. Old high-nonce traffic is not a substitute for a genesis checkpoint. The new node uses an independent database. + +### Scheduling, budgets and recovery + +Set `PROVING_ENABLED=true` to permit paid work. `PROVE_INTERVAL_SECONDS` controls the delay between runs (default 3,600 seconds). The worker checks the queue on startup, serializes its batches, automatically halves the action cap after a local cycle/PGU admission failure (before any purchase), and checks signatures/nonces again before selecting eligible actions. Nonce gaps wait without blocking unrelated meters. Consumed queue entries are retained for audit. A confirmed/finalized chain read is used for queue cleanup; the proof host performs its own current-state checks. + +The host chooses a batch within its mixed fresh/reused slot gas budget, then measures storage-writing gas on the live chain using `eth_estimateGas` with a simulation-only verifier override, shrinks the batch if necessary, reserves verifier overhead and 20% headroom, checks wallet funding and guest identity, and executes locally before purchasing a proof. `MAX_PRICE_PER_PGU=auto` uses the SP1 network's quoted maximum price per PGU, with no added percentage buffer. Set a numeric value for a fixed cap, and optionally `MAX_PROOF_COST_WEI` for an absolute per-proof PROVE limit. Ethereum gas limits do not bound the price of PROVE. Network credit and Ethereum balance checks fail before purchase when insufficient. + +A durable marker is written **before** requesting a paid proof. The returned request ID is saved immediately; the worker automatically recovers that same request after a timeout or restart. Saved proofs are reverified and reused while their old state/keys remain current. Ethereum transactions are signed and saved before broadcasting, so recovery rebroadcasts identical bytes/hash. Only a successful, confirmed, canonical receipt marks selected messages consumed. An ambiguous paid request without a saved request ID pauses further buying; inspect the SP1 request history and recover its ID before proceeding. Stale proofs or reverted transactions also require operator review instead of automatic repurchase. + +```sh +./node recover /data/batches/BATCH_DIRECTORY +``` + +Gas, cycle/PGU, fee and credit limits remain configurable in `.env`/Compose. A valid proof cannot guarantee inclusion if another writer changes state or fees change. Use one worker and one submission wallet for this deployment; unrelated wallet activity is not coordinated. + ## Repository layout - `lib`: signed-action accounting, packed state and the shared statement/wire codec. @@ -36,13 +92,15 @@ The guest's public output is `keccak256(transcript)` (32 bytes). The transcript The used-key hash covers four 32-byte entries: the full key for a changed lane and zero for an unchanged lane. The contract reconstructs this transcript from its live old words/keys and the proposed replacements. Old words and keys do not appear in commit calldata. Destination fields prevent proof reuse on another chain/deployment; old words reject stale updates; indices, new words and keys authenticate the exact transition. Count is implicit and the configured SP1 key identifies the program. +The RPC must support EIP-1898 hash-pinned calls and `eth_estimateGas` state overrides. The latter changes only a simulation; real commits always use the actual verifier. + The host pins its RPC reads to one canonical block hash for consistency, but that block is not committed and imposes no expiry. Proofs for disjoint words can coexist. Proofs sharing a word conflict, even when they change different meters in that word. A proof cannot replay after its own successful transition because old words and nonces have changed. The contract writes in one loop before a static verifier call. Verification failure rolls back all writes and length changes. After success, `StateCommitted(bytes updates)` emits the exact payload for indexers. ## Build and test -Use Rust stable and SP1 **6.3.1**. The proving-enabled host needs protobuf/protoc, pkg-config and OpenSSL development headers. Install SP1 from the official Succinct distribution, pinning `sp1up --version v6.3.1`. +Use Rust **1.99.1** and SP1 **6.3.1**. The proving-enabled host needs protobuf/protoc, pkg-config and OpenSSL development headers. Install SP1 from the official Succinct distribution, pinning `sp1up --version v6.3.1`. ```sh cargo fmt --all -- --check @@ -53,7 +111,7 @@ SP1_ELF=elf/m3tering-program WITNESS_FILE=fixtures/input.json \ cargo run --locked -p m3tering-prover --features proving -- execute-fixture ``` -`execute-fixture` executes the actual guest locally, verifies that its public output matches the shared transition code and prints the program verification key. It neither buys a proof nor submits an Ethereum transaction. Re-derive the key after changing the guest, dependencies or toolchain. Set that key and the target-chain verifier/M3ter addresses in the Rollup constants before deployment. +`execute-fixture` executes the actual guest locally, verifies that its public output matches the shared transition code and prints the program verification key. It neither buys a proof nor submits an Ethereum transaction. Re-derive the key after changing the guest, dependencies or toolchain. Set that key and the target-chain verifier/M3ter addresses before deployment. The Sepolia Rollup permits only its deploying wallet to change the vkey and disables that setter on other chains. To regenerate the cross-language fixtures: @@ -80,27 +138,13 @@ cargo run --locked -p m3tering-prover -- plan cargo run --locked -p m3tering-prover --features proving -- run ``` -Each invocation handles one batch. Schedule subsequent invocations with unique output directories. Keep one submitter per wallet/deployment unless you provide distributed coordination. A local file lock coordinates this host on one machine only. +Each CLI invocation handles one batch. The packaged worker schedules invocations with unique output directories. Keep one submitter per wallet/deployment unless you provide distributed coordination. A local file lock coordinates this host on one machine only. `run` checks the deployed program key against the local ELF before buying a proof. It locally executes the guest, validates the expected public output, bounds cycles/PGU, checks SP1 credits, and applies an explicit network price cap and timeout. Ethereum `PRIVATE_KEY` and `SP1_PRIVATE_KEY` are separate credentials. Never commit real credentials. Before submitting, the host verifies the returned proof, checks current words/used keys again, simulates the call, estimates gas, adds 20% headroom and checks the signer's pending ETH balance using maximum fees. Only a successful, sufficiently confirmed, still-canonical receipt allows selected consumed database messages to be marked verified. -Proofs and transaction receipts are saved. Output directories containing `proof.bin` or `transaction.json` cannot be overwritten. Preserve a paid proof after a send failure; it can be reused only while its old words and used keys remain current. There is no automatic paid retry, queue quarantine, distributed reservation service or submit-only recovery daemon. Malformed entries, nonce gaps and overflow can require operator attention. - -### Streamr ingestion - -The ingestion client subscribes to `STREAM_ID` and saves action bytes/signatures in PostgreSQL. It can run independently of proving: - -```sh -cd streamr-client -npm ci -# Set DATABASE_URL and STREAM_ID in the environment or .env. -# STREAMR_CLIENT_PORT defaults to 3000. -npm start -``` - -Startup applies the checked-in database migration. `/health` serves the process health endpoint. Queue ingestion does not authenticate an energy transition; proof generation and on-chain verification do that. +Proofs, request IDs, signed transactions and receipts are saved. Use the packaged worker or `recover` to resume a paid attempt. Never delete an unfinished attempt to bypass its safety checks. Standalone host database input requires the current ingestion migrations and `queue_status=ready` preparation by the worker. ## Gas admission and capacity diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f736035 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,95 @@ +name: m3tering +x-node: &node + platform: linux/amd64 + image: ${PROVER_IMAGE:-ghcr.io/m3tering/prover:propter-polar-bear} + build: {context: ., target: worker} + restart: unless-stopped + init: true + stop_grace_period: 60s + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + logging: + driver: json-file + options: {max-size: 10m, max-file: '3'} +x-environment: &environment + DATABASE_URL: postgres://m3tering:${POSTGRES_PASSWORD:?Run ./node setup}@db/m3tering + RPC_URL: ${RPC_URL:?Set RPC_URL} + CHAIN_ID: ${CHAIN_ID:-11155111} + ROLLUP_ADDRESS: ${ROLLUP_ADDRESS:?Set ROLLUP_ADDRESS} +services: + db: + image: postgres:16-bookworm@sha256:d81f7b51697014367236d07ec58b54153fc28f828b72bf247fe5c7487520bcb0 + restart: unless-stopped + environment: + POSTGRES_DB: m3tering + POSTGRES_USER: m3tering + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run ./node setup} + volumes: [database:/var/lib/postgresql/data] + healthcheck: + test: [CMD-SHELL, 'pg_isready -U m3tering -d m3tering'] + interval: 5s + timeout: 5s + retries: 20 + init-data: + <<: *node + restart: 'no' + image: ${API_IMAGE:-ghcr.io/m3tering/prover:propter-polar-bear-api} + build: {context: ., target: api} + user: root + cap_drop: [] + command: [sh, -c, 'mkdir -p /data/batches /data/cache && chown -R 1000:1000 /data'] + volumes: [proofs:/data] + api: + <<: *node + mem_limit: ${API_MEMORY:-512m} + image: ${API_IMAGE:-ghcr.io/m3tering/prover:propter-polar-bear-api} + build: {context: ., target: api} + ports: ['127.0.0.1:${HTTP_PORT:-19000}:3000'] + environment: + <<: *environment + STREAM_ID: ${STREAM_ID:-} + STREAMR_PRIVATE_KEY: ${STREAMR_PRIVATE_KEY:-} + MAX_BATCH_ACTIONS: ${MAX_BATCH_ACTIONS:-1000} + depends_on: + db: {condition: service_healthy} + healthcheck: + test: [CMD, node, -e, 'fetch("http://127.0.0.1:3000/health").then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))'] + interval: 15s + timeout: 5s + retries: 20 + worker: + <<: *node + mem_limit: ${WORKER_MEMORY:-4g} + command: [node, worker-main.js] + environment: + <<: *environment + PRIVATE_KEY: ${PRIVATE_KEY:?Set PRIVATE_KEY} + SP1_PRIVATE_KEY: ${SP1_PRIVATE_KEY:?Set SP1_PRIVATE_KEY} + SP1_ELF: /opt/m3tering/release/program.elf + SP1_CACHE_DIR: /data/cache + PROVING_ENABLED: ${PROVING_ENABLED:-false} + PROVE_INTERVAL_SECONDS: ${PROVE_INTERVAL_SECONDS:-3600} + MAX_ACTIONS: ${MAX_ACTIONS:-10000} + MAX_SLOTS: ${MAX_SLOTS:-4096} + GAS_SCHEDULE: ${GAS_SCHEDULE:-current} + EXECUTION_GAS_BUDGET: ${EXECUTION_GAS_BUDGET:-15000000} + TOTAL_GAS_BUDGET: ${TOTAL_GAS_BUDGET:-15000000} + FIXED_EXECUTION_ALLOWANCE: ${FIXED_EXECUTION_ALLOWANCE:-1500000} + PER_WORD_OVERHEAD_ALLOWANCE: ${PER_WORD_OVERHEAD_ALLOWANCE:-12000} + MAX_FEE_PER_GAS_WEI: ${MAX_FEE_PER_GAS_WEI:-30000000000} + MAX_PRIORITY_FEE_PER_GAS_WEI: ${MAX_PRIORITY_FEE_PER_GAS_WEI:-1000000000} + ETH_RESERVE_WEI: ${ETH_RESERVE_WEI:-1000000000000000} + MAX_PROVER_CYCLES: ${MAX_PROVER_CYCLES:-100000000} + MAX_PROVER_PGU: ${MAX_PROVER_PGU:-100000000} + MAX_PRICE_PER_PGU: ${MAX_PRICE_PER_PGU:-auto} + MAX_PROOF_COST_WEI: ${MAX_PROOF_COST_WEI:-} + SP1_CREDIT_RESERVE_WEI: ${SP1_CREDIT_RESERVE_WEI:-0} + PROOF_TIMEOUT_SECONDS: ${PROOF_TIMEOUT_SECONDS:-3600} + CONFIRMATIONS: ${CONFIRMATIONS:-2} + volumes: [proofs:/data] + depends_on: + api: {condition: service_healthy} + init-data: {condition: service_completed_successfully} +volumes: + database: + proofs: diff --git a/host/src/main.rs b/host/src/main.rs index 9ae9b22..01468ea 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -1,5 +1,7 @@ mod budget; use alloy::{ + eips::Encodable2718, + network::{EthereumWallet, TransactionBuilder}, providers::{Provider, ProviderBuilder}, rpc::types::TransactionRequest, signers::local::PrivateKeySigner, @@ -129,7 +131,7 @@ async fn actions() -> Result>> { } }); let limit = i64::try_from(env_u64("MAX_ACTIONS", 10_000)?)?; - let rows=client.query("SELECT m3ter_id,message,signature FROM m3ter_payloads WHERE is_verified = false ORDER BY m3ter_id,nonce,id LIMIT $1",&[&limit]).await?; + let rows=client.query("SELECT m3ter_id,message,signature FROM m3ter_payloads WHERE is_verified = false AND queue_status = 'ready' ORDER BY m3ter_id,nonce,id LIMIT $1",&[&limit]).await?; let mut result = BTreeMap::>::new(); for row in rows { let id: i64 = row.get(0); @@ -203,14 +205,40 @@ async fn plan(rpc: &Rpc, rollup: Address) -> Result<(Input, Transition, budget:: } pending.retain(|m, _| words.contains_key(&(m / 4))); ensure!(!pending.is_empty(), "no actions fit gas budgets"); - let input = Input { + let mut input = Input { chain_id, rollup, words, keys, actions: pending, }; - let t = transition::execute(&input).map_err(|e| anyhow!(e))?; + let mut t = transition::execute(&input).map_err(|e| anyhow!(e))?; + ensure!(!t.updates.is_empty(), "no eligible state transitions"); + // Measure slot-writing cost on the actual target fork before buying a proof. + // Only the simulation's verifier code is replaced with STOP. The real + // transaction always uses the deployed verifier and its genuine proof. + let mut low = 0usize; + let mut high = t.updates.len(); + while low < high { + let count = (low + high + 1) / 2; + let payload = &t.payload[..count * 36]; + let fits = estimate_update_gas(rpc, rollup, payload, b) + .await + .is_ok_and(|gas| gas <= b.total_limit.min(b.execution_limit)); + if fits { + low = count; + } else { + high = count - 1; + } + } + ensure!(low > 0, "live gas admission failed: RPC must support eth_estimateGas state overrides and at least one slot must fit"); + if low < t.updates.len() { + let last = t.updates[low - 1].slot; + input.actions.retain(|id, _| id / 4 <= last); + input.words.retain(|slot, _| *slot <= last); + input.keys.retain(|slot, _| *slot <= last); + t = transition::execute(&input).map_err(|e| anyhow!(e))?; + } println!( "Planned {} words, {} packed payload bytes, 32 public-value bytes", t.updates.len(), @@ -218,6 +246,34 @@ async fn plan(rpc: &Rpc, rollup: Address) -> Result<(Input, Transition, budget:: ); Ok((input, t, b)) } +async fn estimate_update_gas( + rpc: &Rpc, + rollup: Address, + payload: &[u8], + b: budget::Budget, +) -> Result { + let verifier = rpc + .view(rollup, "VERIFIER", json!("latest")) + .await? + .to_be_bytes::<32>(); + let verifier = Address::from_slice(&verifier[12..]); + let data = commitStateCall { + updates: payload.to_vec().into(), + proof: vec![255u8; 356].into(), + } + .abi_encode(); + let overrides = json!({format!("{verifier}"): {"code":"0x00"}}); + let estimate = rpc.call("eth_estimateGas", json!([ + {"to":rollup,"data":format!("0x{}",hex::encode(data)),"gas":format!("0x{:x}",b.total_limit)}, "latest", overrides + ])).await?; + let measured: u64 = quantity(&estimate)?.try_into()?; + measured + .checked_add(b.fixed_execution) + .and_then(|v| v.checked_mul(6)) + .and_then(|v| v.checked_add(4)) + .map(|v| v / 5) + .context("gas estimate overflow") +} async fn preflight(rpc: &Rpc, input: &Input, t: &Transition) -> Result<()> { let head = rpc .call("eth_getBlockByNumber", json!(["latest", false])) @@ -304,14 +360,44 @@ async fn prove_and_send( .open(std::env::temp_dir().join(format!("m3ter-prover-{}.lock", signer.address())))?; lock.try_lock_exclusive() .context("another prover batch is using this wallet")?; + if out.join("transaction.json").exists() { + return recover_transaction(rpc, input, t, out).await; + } preflight(rpc, input, t).await?; - let (_, estimated) = b.estimate(&t.updates.iter().map(|u| u.old).collect::>())?; - let estimated = estimated - .checked_mul(6) - .and_then(|x| x.checked_add(4)) - .map(|x| x / 5) - .ok_or_else(|| anyhow!("gas budget overflow"))?; + let estimated = estimate_update_gas(rpc, input.rollup, &t.payload, b).await?; + ensure!( + estimated <= b.total_limit.min(b.execution_limit), + "live gas estimate now exceeds budget; do not buy a proof" + ); funding(rpc, signer.address(), estimated).await?; + // SP1 6.3.1: first four bytes of SHA-256(groth16_vk.bin). + // Check the actual gateway route before purchasing a proof. + let verifier = rpc + .view(input.rollup, "VERIFIER", json!("latest")) + .await? + .to_be_bytes::<32>(); + let gateway = Address::from_slice(&verifier[12..]); + let mut route_data = keccak256("routes(bytes4)").as_slice()[..4].to_vec(); + route_data.extend([0x43, 0x88, 0xa2, 0x1c]); + route_data.extend([0u8; 28]); + let route = rpc + .call( + "eth_call", + json!([{"to":gateway,"data":format!("0x{}",hex::encode(route_data))},"latest"]), + ) + .await?; + let route = hex::decode( + route + .as_str() + .context("invalid verifier route response")? + .trim_start_matches("0x"), + )?; + ensure!( + route.len() == 64 + && route[..32].iter().any(|x| *x != 0) + && route[32..].iter().all(|x| *x == 0), + "SP1 verifier route missing or frozen; do not buy a proof" + ); let network_key = required("SP1_PRIVATE_KEY")?; let network_signer = NetworkSigner::local(&network_key)?; let client = ProverClient::builder() @@ -328,70 +414,133 @@ async fn prove_and_send( B256::from(onchain.to_be_bytes::<32>()) == B256::from_str(&pk.verifying_key().bytes32())?, "deployed program key differs from prover guest" ); - let mut stdin = SP1Stdin::new(); - stdin.write(input); - // Execute locally before requesting a paid proof. Compare complete public values. - let (public, report) = client - .execute(elf, stdin.clone()) - .cycle_limit(env_u64("MAX_PROVER_CYCLES", 100_000_000)?) - .calculate_gas(true) - .await?; - ensure!( - public.as_slice() == t.public_values.as_slice(), - "guest/host statement mismatch" - ); - let cycles = report - .total_instruction_count() - .checked_mul(110) - .and_then(|v| v.checked_div(100)) - .ok_or_else(|| anyhow!("cycle overflow"))?; - let pgu = report - .gas() - .ok_or_else(|| anyhow!("SP1 execution did not report PGU"))? - .checked_mul(110) - .and_then(|v| v.checked_div(100)) - .ok_or_else(|| anyhow!("PGU overflow"))?; - ensure!( - cycles <= env_u64("MAX_PROVER_CYCLES", 100_000_000)? - && pgu <= env_u64("MAX_PROVER_PGU", 100_000_000)?, - "batch exceeds configured proving budget; reduce MAX_SLOTS/MAX_ACTIONS" - ); - let price = required("MAX_PRICE_PER_PGU")?.parse::()?; - let params = client - .get_proof_request_params(SP1ProofMode::Groth16) - .await?; - let params = match params { - sp1_sdk::network::proto::GetProofRequestParamsResponse::Auction(p) => p, - _ => bail!("network does not provide auction prices"), + let timeout = Some(std::time::Duration::from_secs(env_u64( + "PROOF_TIMEOUT_SECONDS", + 3600, + )?)); + let proof = if out.join("proof.bin").exists() { + sp1_sdk::SP1ProofWithPublicValues::load(out.join("proof.bin"))? + } else if out.join("request.json").exists() { + let saved: Value = serde_json::from_slice(&std::fs::read(out.join("request.json"))?)?; + let request_id = B256::from_str( + saved["request_id"] + .as_str() + .context("missing SP1 request ID")?, + )?; + client.wait_proof(request_id, timeout, None).await? + } else { + ensure!( + !out.join("paid-attempt.json").exists(), + "ambiguous paid request: inspect network history before any new request" + ); + let mut stdin = SP1Stdin::new(); + stdin.write(input); + // Execute locally before requesting a paid proof. Compare complete public values. + let execution = client + .execute(elf, stdin.clone()) + .cycle_limit(env_u64("MAX_PROVER_CYCLES", 100_000_000)?) + .calculate_gas(true) + .await; + if let Err(error) = &execution { + if error.to_string().contains("exceeded cycle limit") { + durable_json( + &out.join("reduce-batch.json"), + &json!({"reason":"cycle limit"}), + )?; + } + } + let (public, report) = execution?; + ensure!( + public.as_slice() == t.public_values.as_slice(), + "guest/host statement mismatch" + ); + let cycles = report + .total_instruction_count() + .checked_mul(110) + .and_then(|v| v.checked_div(100)) + .ok_or_else(|| anyhow!("cycle overflow"))?; + let pgu = report + .gas() + .ok_or_else(|| anyhow!("SP1 execution did not report PGU"))? + .checked_mul(110) + .and_then(|v| v.checked_div(100)) + .ok_or_else(|| anyhow!("PGU overflow"))?; + if cycles > env_u64("MAX_PROVER_CYCLES", 100_000_000)? + || pgu > env_u64("MAX_PROVER_PGU", 100_000_000)? + { + durable_json( + &out.join("reduce-batch.json"), + &json!({"reason":"cycle/PGU admission"}), + )?; + bail!("batch exceeds configured proving budget; reduce action count before purchasing"); + } + let params = client + .get_proof_request_params(SP1ProofMode::Groth16) + .await?; + let params = match params { + sp1_sdk::network::proto::GetProofRequestParamsResponse::Auction(p) => p, + _ => bail!("network does not provide auction prices"), + }; + let price = match std::env::var("MAX_PRICE_PER_PGU").as_deref() { + Ok("auto") => params.max_price_per_pgu.parse::()?, + Ok(value) => value.parse::()?, + Err(_) => bail!("set MAX_PRICE_PER_PGU to auto or an explicit PROVE-wei cap"), + }; + ensure!( + params.tick_size == 0 || price % params.tick_size == 0, + "MAX_PRICE_PER_PGU must be a multiple of the auction tick" + ); + let base = params.base_fee.parse::()?; + let reserve = U256::from_str(&required("SP1_CREDIT_RESERVE_WEI")?)?; + let cost = U256::from(pgu) + .checked_mul(U256::from(price)) + .and_then(|x| x.checked_add(U256::from(base))) + .and_then(|x| x.checked_add(reserve)) + .ok_or_else(|| anyhow!("credit cost overflow"))?; + ensure!( + client.get_balance().await? >= cost, + "insufficient SP1 network credits (PROVE wei)" + ); + if let Ok(cap) = std::env::var("MAX_PROOF_COST_WEI") { + if !cap.is_empty() { + let cap = U256::from_str(&cap)?; + ensure!( + cost.checked_sub(reserve).is_some_and(|v| v <= cap), + "proof quote exceeds MAX_PROOF_COST_WEI" + ); + } + } + preflight(rpc, input, t).await?; + funding(rpc, signer.address(), estimated).await?; + // Durable marker BEFORE any paid request. An ambiguous failure pauses the scheduler, + // including after a restart, instead of buying the same proof again. + let marker = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(out.join("paid-attempt.json"))?; + serde_json::to_writer_pretty( + &marker, + &json!({"cycles":cycles,"pgu":pgu,"price_per_pgu":price,"maximum_cost_wei":cost.checked_sub(reserve),"program_vkey":pk.verifying_key().bytes32()}), + )?; + marker.sync_all()?; + let request_id = client + .prove(&pk, stdin) + .groth16() + .cycle_limit(cycles) + .gas_limit(pgu) + .max_price_per_pgu(price) + .timeout(std::time::Duration::from_secs(env_u64( + "PROOF_TIMEOUT_SECONDS", + 3600, + )?)) + .request() + .await?; + durable_json( + &out.join("request.json"), + &json!({"request_id": request_id}), + )?; + client.wait_proof(request_id, timeout, None).await? }; - ensure!( - params.tick_size == 0 || price % params.tick_size == 0, - "MAX_PRICE_PER_PGU must be a multiple of the auction tick" - ); - let base = params.base_fee.parse::()?; - let reserve = U256::from_str(&required("SP1_CREDIT_RESERVE_WEI")?)?; - let cost = U256::from(pgu) - .checked_mul(U256::from(price)) - .and_then(|x| x.checked_add(U256::from(base))) - .and_then(|x| x.checked_add(reserve)) - .ok_or_else(|| anyhow!("credit cost overflow"))?; - ensure!( - client.get_balance().await? >= cost, - "insufficient SP1 network credits (PROVE wei)" - ); - preflight(rpc, input, t).await?; - funding(rpc, signer.address(), estimated).await?; - let proof = client - .prove(&pk, stdin) - .groth16() - .cycle_limit(cycles) - .gas_limit(pgu) - .max_price_per_pgu(price) - .timeout(std::time::Duration::from_secs(env_u64( - "PROOF_TIMEOUT_SECONDS", - 3600, - )?)) - .await?; client.verify(&proof, pk.verifying_key(), None)?; ensure!( proof.public_values.as_slice() == t.public_values.as_slice(), @@ -410,6 +559,7 @@ async fn prove_and_send( quantity(&rpc.call("eth_estimateGas", json!([request])).await?)?.try_into()?; let gas = actual .checked_mul(120) + .and_then(|v| v.checked_add(99)) .and_then(|v| v.checked_div(100)) .ok_or_else(|| anyhow!("gas overflow"))?; ensure!( @@ -417,20 +567,33 @@ async fn prove_and_send( "final gas with 20% headroom exceeds budget; keep proof, do not send" ); let (max_fee, priority) = funding(rpc, signer.address(), gas).await?; - let provider = ProviderBuilder::new() - .wallet(signer) - .connect_http(rpc.url.parse()?); + let nonce: u64 = quantity( + &rpc.call( + "eth_getTransactionCount", + json!([signer.address(), "pending"]), + ) + .await?, + )? + .try_into()?; + let wallet = EthereumWallet::from(signer); let tx = TransactionRequest::default() .to(input.rollup) .input(Bytes::from(data).into()) + .nonce(nonce) + .chain_id(input.chain_id.try_into()?) .gas_limit(gas) .max_fee_per_gas(max_fee) .max_priority_fee_per_gas(priority); - let pending = provider.send_transaction(tx).await?; - std::fs::write( - out.join("transaction.json"), - serde_json::to_vec_pretty(&json!({"hash":pending.tx_hash()}))?, + let signed = tx.build(&wallet).await?; + let raw = signed.encoded_2718(); + // Save signed bytes BEFORE broadcasting. Recovery rebroadcasts the identical + // transaction/hash, never a newly signed transaction with a second nonce. + durable_json( + &out.join("transaction.json"), + &json!({"hash":signed.tx_hash(),"raw":format!("0x{}",hex::encode(&raw))}), )?; + let provider = ProviderBuilder::new().connect_http(rpc.url.parse()?); + let pending = provider.send_raw_transaction(&raw).await?; let receipt = pending .with_required_confirmations(env_u64("CONFIRMATIONS", 2)?) .get_receipt() @@ -462,6 +625,74 @@ async fn prove_and_send( ); reconcile(input, t).await?; } + std::fs::write( + out.join("complete.json"), + serde_json::to_vec(&json!({"transaction":receipt.transaction_hash}))?, + )?; + Ok(()) +} +fn durable_json(path: &Path, value: &Value) -> Result<()> { + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; + serde_json::to_writer(&file, value)?; + file.sync_all()?; + if let Some(parent) = path.parent() { + std::fs::File::open(parent)?.sync_all()?; + } + Ok(()) +} +#[cfg(feature = "proving")] +async fn recover_transaction(rpc: &Rpc, input: &Input, t: &Transition, out: &Path) -> Result<()> { + ensure!( + quantity(&rpc.call("eth_chainId", json!([])).await?)? == input.chain_id, + "wrong recovery chain" + ); + let saved: Value = serde_json::from_slice(&std::fs::read(out.join("transaction.json"))?)?; + let hash = saved["hash"].as_str().context("missing transaction hash")?; + if let Some(raw) = saved["raw"].as_str() { + ensure!( + format!("{}", keccak256(hex::decode(raw.trim_start_matches("0x"))?)) == hash, + "saved transaction hash mismatch" + ); + let _ = rpc.call("eth_sendRawTransaction", json!([raw])).await; + } + let receipt = rpc + .call("eth_getTransactionReceipt", json!([hash])) + .await + .context("transaction still pending; retry recovery later")?; + ensure!( + quantity(&receipt["status"])? == U256::from(1), + "transaction reverted; manual review required" + ); + let number: u64 = quantity(&receipt["blockNumber"])?.try_into()?; + let head: u64 = quantity(&rpc.call("eth_blockNumber", json!([])).await?)?.try_into()?; + ensure!( + head.saturating_sub(number) + 1 >= env_u64("CONFIRMATIONS", 2)?, + "awaiting confirmations" + ); + let block = rpc + .call( + "eth_getBlockByNumber", + json!([receipt["blockNumber"], false]), + ) + .await?; + ensure!( + block["hash"] == receipt["blockHash"], + "receipt is not canonical" + ); + std::fs::write( + out.join("receipt.json"), + serde_json::to_vec_pretty(&receipt)?, + )?; + if std::env::var_os("ACTIONS_FILE").is_none() { + reconcile(input, t).await?; + } + if !out.join("complete.json").exists() { + durable_json(&out.join("complete.json"), &json!({"transaction":hash}))?; + } + println!("Recovered committed transaction {hash}"); Ok(()) } #[cfg(feature = "proving")] @@ -516,16 +747,45 @@ async fn execute_fixture() -> Result<()> { ); Ok(()) } +#[cfg(feature = "proving")] +async fn network_status() -> Result<()> { + use sp1_sdk::{ + network::{signer::NetworkSigner, NetworkMode}, + ProverClient, SP1ProofMode, + }; + let client = ProverClient::builder() + .network_for(NetworkMode::Mainnet) + .signer(NetworkSigner::local(&required("SP1_PRIVATE_KEY")?)?) + .build() + .await; + println!("SP1 balance (PROVE wei): {}", client.get_balance().await?); + match client + .get_proof_request_params(SP1ProofMode::Groth16) + .await? + { + sp1_sdk::network::proto::GetProofRequestParamsResponse::Auction(p) => println!( + "Network quote: {} PROVE wei/PGU; base fee {} PROVE wei; tick {}", + p.max_price_per_pgu, p.base_fee, p.tick_size + ), + _ => bail!("unsupported network pricing mode"), + } + Ok(()) +} #[tokio::main] async fn main() -> Result<()> { let mode = std::env::args().nth(1).unwrap_or_else(|| "plan".into()); + #[cfg(feature = "proving")] + if mode == "network-status" { + return network_status().await; + } + #[cfg(feature = "proving")] if mode == "execute-fixture" { return execute_fixture().await; } ensure!( - mode == "plan" || mode == "run", - "usage: prover-rollup-host [plan|run]" + mode == "plan" || mode == "run" || mode == "recover", + "usage: m3tering-prover [plan|run|recover|execute-fixture]" ); let rpc = Rpc { url: required("RPC_URL")?, @@ -538,6 +798,32 @@ async fn main() -> Result<()> { std::env::var("OUTPUT_DIR").unwrap_or_else(|_| "prover-output".into()), ); std::fs::create_dir_all(&out)?; + if mode == "recover" { + #[cfg(feature = "proving")] + { + ensure!( + out.join("paid-attempt.json").exists(), + "no paid attempt to recover" + ); + let input: Input = serde_json::from_slice(&std::fs::read(out.join("input.json"))?)?; + ensure!(input.rollup == rollup, "recovery Rollup mismatch"); + let t = transition::execute(&input).map_err(|e| anyhow!(e))?; + let head = rpc + .call("eth_getBlockByNumber", json!(["latest", false])) + .await?; + return prove_and_send( + &rpc, + &input, + &t, + budget(quantity(&head["gasLimit"])?.try_into()?)?, + &out, + ) + .await; + } + #[cfg(not(feature = "proving"))] + bail!("recovery requires --features proving"); + } + ensure!(!out.join("proof.bin").exists() && !out.join("transaction.json").exists(),"OUTPUT_DIR contains a paid proof or transaction: choose a new directory; do not overwrite it"); let (input, t, b) = plan(&rpc, rollup).await?; std::fs::write(out.join("input.json"), serde_json::to_vec_pretty(&input)?)?; diff --git a/node b/node new file mode 100755 index 0000000..16da69e --- /dev/null +++ b/node @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +cd -- "$(dirname -- "$0")" +case "${1:-help}" in + setup) docker run --rm -it --user "$(id -u):$(id -g)" -v "$PWD:/work" -w /work node:24.16.0-bookworm-slim@sha256:2c87ef9bd3c6a3bd4b472b4bec2ce9d16354b0c574f736c476489d09f560a203 node ops/setup.js ;; + start) docker compose up -d --no-build --pull missing ;; + update) docker compose pull; docker compose up -d --no-build ;; + build) docker compose build ;; + stop) docker compose stop ;; + status) docker compose ps; docker compose exec -T worker node -e 'console.log(require("fs").readFileSync("/data/status.json","utf8"))' ;; + logs) docker compose logs --tail 100 -f "${2:-worker}" ;; + plan) docker compose exec -e OUTPUT_DIR=/tmp/prover-plan worker m3tering-prover plan ;; + network) docker compose exec worker m3tering-prover network-status ;; + recover) test -n "${2:-}" || { echo 'Usage: ./node recover /data/batches/BATCH'; exit 1; }; docker compose exec -e OUTPUT_DIR="$2" worker m3tering-prover recover ;; + backup) mkdir -p backups; umask 077; docker compose exec -T db pg_dump -U m3tering -d m3tering -Fc > "backups/queue-$(date -u +%Y%m%dT%H%M%SZ).dump"; echo 'Database backup written to backups/ (also preserve the proofs volume).' ;; + *) echo 'Usage: ./node {setup|start|update|build|stop|status|logs [api|worker]|plan|network|recover BATCH|backup}' ;; +esac diff --git a/ops/Dockerfile.release b/ops/Dockerfile.release new file mode 100644 index 0000000..bbb336b --- /dev/null +++ b/ops/Dockerfile.release @@ -0,0 +1,10 @@ +# Build only from a verified local release artifact; normal source builds use ../Dockerfile. +ARG API_IMAGE=m3tering-api:polar-bear +FROM ${API_IMAGE} +COPY m3tering-prover /usr/local/bin/m3tering-prover +COPY program.elf program.sha256 program-build.json /opt/m3tering/release/ +WORKDIR /opt/m3tering +RUN sha256sum -c release/program.sha256 +ENV SP1_ELF=/opt/m3tering/release/program.elf BATCH_DIR=/data/batches +WORKDIR /app +CMD ["node", "worker-main.js"] diff --git a/ops/deploy-rollup.cjs b/ops/deploy-rollup.cjs new file mode 100644 index 0000000..1d767d6 --- /dev/null +++ b/ops/deploy-rollup.cjs @@ -0,0 +1,48 @@ +// Authorized Sepolia deployment helper. Never prints keys or RPC URLs. +const fs = require('fs'), path = require('path') +const { JsonRpcProvider, Wallet, ContractFactory, Contract, getCreateAddress } = require('../streamr-client/node_modules/ethers') +const dotenv = require('../streamr-client/node_modules/dotenv') +async function main() { + const env = dotenv.parse(fs.readFileSync(process.env.DEPLOY_ENV || '.env')) + const provider = new JsonRpcProvider(env.RPC_URL), signer = new Wallet(env.PRIVATE_KEY, provider) + if ((await provider.getNetwork()).chainId !== 11155111n) throw new Error('Wrong chain') + const artifact = JSON.parse(fs.readFileSync(path.resolve(__dirname,'../../rollup/out/Rollup.sol/Rollup.json'))) + const dir = path.resolve(__dirname,'../../rollup/deployments');fs.mkdirSync(dir,{recursive:true}) + const file = path.join(dir,'sepolia.json') + let record + if (fs.existsSync(file)) record = JSON.parse(fs.readFileSync(file)) + else { + const factory = new ContractFactory(artifact.abi, artifact.bytecode.object, signer) + const tx = await factory.getDeployTransaction() + const nonce = await signer.getNonce('pending'), gas = await provider.estimateGas({...tx,from:signer.address}) + const fees = await provider.getFeeData() + const maxFeePerGas=fees.maxFeePerGas, maxPriorityFeePerGas=fees.maxPriorityFeePerGas + if (!maxFeePerGas || maxFeePerGas > 30000000000n) throw new Error('Fee cap exceeded') + const gasLimit=gas*120n/100n + if(await provider.getBalance(signer.address,'pending') < gasLimit*maxFeePerGas+1000000000000000n) throw new Error('Insufficient deployment balance') + const raw=await signer.signTransaction({...tx,chainId:11155111,nonce,gasLimit,maxFeePerGas,maxPriorityFeePerGas,type:2}) + record={chainId:11155111,address:getCreateAddress({from:signer.address,nonce}),deployer:signer.address,rawDeploymentTransaction:raw,programVKey:'0x00d7ccad99253ce7ee8b85917c04d23c3685645613492931c9ad82efecefe06d',elfSha256:'1cdba7eb2eae2c6839ec69074a9053cbf0cce030cb16d60642e8122145a3fe12'} + fs.writeFileSync(file,JSON.stringify(record,null,2)+'\n') + } + if(await provider.getCode(record.address)==='0x') { + const sent=await provider.broadcastTransaction(record.rawDeploymentTransaction) + record.deploymentTransaction=sent.hash;fs.writeFileSync(file,JSON.stringify(record,null,2)+'\n') + const receipt=await sent.wait(2);if(receipt.status!==1)throw new Error('Deployment reverted') + record.deploymentBlock=receipt.blockNumber;record.deploymentGas=receipt.gasUsed.toString() + } + const rollup=new Contract(record.address,artifact.abi,signer) + if(await rollup.VKEY_ADMIN()!==signer.address)throw new Error('Unexpected admin') + if(await rollup.SP1_PROGRAM_VKEY()!==record.programVKey)throw new Error('Unexpected vkey') + const key='0x2e653ffbcf13dabbf1a41d112171bab4ca1d3bac00e98d80cdce7cce51920608' + if(await rollup.publicKey(7)!==key) { + const sent=await rollup.setPublicKey(7,key) + record.meter7KeyTransaction=sent.hash;fs.writeFileSync(file,JSON.stringify(record,null,2)+'\n') + const receipt=await sent.wait(2);if(receipt.status!==1)throw new Error('Key registration reverted') + } + record.meter7PublicKey=await rollup.publicKey(7) + record.verifier=await rollup.VERIFIER();record.m3ter=await rollup.M3ter();record.genesisWordCount=String(await rollup.wordCount()) + delete record.rawDeploymentTransaction + fs.writeFileSync(file,JSON.stringify(record,null,2)+'\n') + console.log(JSON.stringify(record,null,2)) +} +main().catch(e=>{console.error('Deployment stopped:',e.code || e.message);process.exit(1)}) diff --git a/ops/setup.js b/ops/setup.js new file mode 100644 index 0000000..2bd19c4 --- /dev/null +++ b/ops/setup.js @@ -0,0 +1,26 @@ +'use strict' +const fs = require('node:fs'), crypto = require('node:crypto'), readline = require('node:readline') +const { Writable } = require('node:stream') +let hidden = false +const output = new Writable({write(chunk,_encoding,done){if(!hidden)process.stdout.write(chunk);done()}}) +const rl = readline.createInterface({input:process.stdin,output,terminal:true}) +const ask = (label,secret=false) => new Promise(resolve=>{process.stdout.write(label+': ');hidden=secret;rl.question('',answer=>{hidden=false;if(secret)process.stdout.write('\n');resolve(answer.trim())})}) +async function main(){ + if(fs.existsSync('.env'))throw new Error('.env already exists; edit it directly to preserve credentials') + const env={COMPOSE_PROJECT_NAME:'m3tering',POSTGRES_PASSWORD:crypto.randomBytes(32).toString('hex'),CHAIN_ID:'11155111',HTTP_PORT:'19000'} + env.RPC_URL=await ask('Sepolia RPC URL');if(!/^https?:\/\//.test(env.RPC_URL))throw new Error('Invalid RPC URL') + env.ROLLUP_ADDRESS=await ask('Rollup address');if(!/^0x[0-9a-fA-F]{40}$/.test(env.ROLLUP_ADDRESS))throw new Error('Invalid Rollup address') + env.PRIVATE_KEY=await ask('Ethereum private key (hidden)',true);if(!/^(0x)?[0-9a-fA-F]{64}$/.test(env.PRIVATE_KEY))throw new Error('Invalid Ethereum key') + env.SP1_PRIVATE_KEY=await ask('SP1 key (hidden; Enter to use the Ethereum key)',true)||env.PRIVATE_KEY + if(!/^(0x)?[0-9a-fA-F]{64}$/.test(env.SP1_PRIVATE_KEY))throw new Error('Invalid SP1 key') + env.STREAM_ID=await ask('Streamr stream ID (Enter to disable Streamr)') + env.PROVE_INTERVAL_SECONDS=await ask('Proof interval in seconds [3600]')||'3600' + if(!/^\d+$/.test(env.PROVE_INTERVAL_SECONDS)||Number(env.PROVE_INTERVAL_SECONDS)<30)throw new Error('Interval must be at least 30 seconds') + env.MAX_PRICE_PER_PGU='auto';env.PROVING_ENABLED=(await ask('Enable paid scheduled proving? [yes/no]')).toLowerCase()==='yes'?'true':'false' + env.MAX_PROOF_COST_WEI=await ask('Optional maximum PROVE wei per proof (Enter for live network quote)') + if(env.MAX_PROOF_COST_WEI&&!/^\d+$/.test(env.MAX_PROOF_COST_WEI))throw new Error('Cost must be a nonnegative integer') + for(const value of Object.values(env))if(/[\r\n']/.test(value))throw new Error('Unsupported configuration character') + fs.writeFileSync('.env',Object.entries(env).map(([k,v])=>`${k}='${v}'`).join('\n')+'\n',{mode:0o600,flag:'wx'}) + console.log('Saved private configuration. Run ./node start, then ./node status.') +} +main().catch(e=>{console.error(e.message);process.exitCode=1}).finally(()=>rl.close()) diff --git a/ops/test-prover.nginx.conf b/ops/test-prover.nginx.conf new file mode 100644 index 0000000..7bc8b22 --- /dev/null +++ b/ops/test-prover.nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + listen [::]:80; + server_name test-prover.m3ter.ing; + client_max_body_size 256k; + location / { + proxy_pass http://127.0.0.1:19000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } +} diff --git a/release/program-build.json b/release/program-build.json new file mode 100644 index 0000000..f66655a --- /dev/null +++ b/release/program-build.json @@ -0,0 +1,12 @@ +{ + "rollup_pr": "https://github.com/M3tering/Rollup/pull/1", + "prover_pr": "https://github.com/M3tering/Prover/pull/13", + "rollup_commit": "9c69f92507a3d46d3340c03aee56c87d9336bc62", + "prover_commit": "7c91cda9b03e29e3de11c83325a02df91ec34e5d", + "program_key": "0x00d7ccad99253ce7ee8b85917c04d23c3685645613492931c9ad82efecefe06d", + "elf_sha256": "1cdba7eb2eae2c6839ec69074a9053cbf0cce030cb16d60642e8122145a3fe12", + "cycles": 594770, + "pgu": 698633, + "solidity_tests": 22, + "rust_tests": 16 +} diff --git a/release/program.elf b/release/program.elf new file mode 100755 index 0000000000000000000000000000000000000000..b1c52244fa8c177f733cde666ceb80fa3194af17 GIT binary patch literal 164808 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%0A7za6NmRCK{c~!~2v>y6FCWz8o=cRX!E99%`o& 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

<|YPFZKxDpqifH#r}l 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 J-j~V!{~NZt { - const { nonce, energy, message, signature } = decodePayload(msg.message); - const exist = await db.oneOrNone( - 'SELECT id FROM m3ter_payloads WHERE m3ter_id = $1 AND nonce = $2', - [msg.m3ter_id, nonce] - ); - if (exist) return null - - return { - m3ter_id: msg.m3ter_id, - nonce, - energy, - message, - signature, - is_verified: false - }; - })) - const values = mapped.filter(Boolean) - - if (values.length === 0) { - console.log("No new messages to save"); - return; - } - - const query = pgp.helpers.insert(values, cs); - await db.none(query); - console.log("πŸ’Ύ Saving batch messages:", values.length); -} - -module.exports = { - saveGossipMessage -} \ No newline at end of file diff --git a/streamr-client/index.js b/streamr-client/index.js index becf749..5995f35 100644 --- a/streamr-client/index.js +++ b/streamr-client/index.js @@ -1,72 +1,72 @@ - +'use strict' +require('dotenv').config({ quiet: true }) const express = require('express') -const events = require('events') -const dotenv = require('dotenv') - -dotenv.config() - -const { streamr, STREAM_ID } = require('./util/streamr-client') -const { saveGossipMessage } = require('./db/save-gossip-message') - const pgp = require('pg-promise')() +const { Chain, InvalidPayload, ingest } = require('./validation') const MigrationManager = require('./db/migration-manager') -async function initializeDatabase() { - const db = pgp(process.env.DATABASE_URL) - const migrationManager = new MigrationManager(db) - - try { - await migrationManager.runMigrations() - console.log('Migrations completed successfully') - } catch (error) { - console.error('Migration failed:', error) - process.exit(1) +async function main() { + for (const key of ['DATABASE_URL', 'RPC_URL', 'ROLLUP_ADDRESS', 'CHAIN_ID']) { + if (!process.env[key]) throw new Error(`Set ${key}`) + } + const db = pgp(process.env.DATABASE_URL) + // A dedicated connection keeps the migration advisory lock on the same session. + await db.task(async connection => new MigrationManager(connection, require('path').join(__dirname, 'db/migrations')).runMigrations()) + const identity = [String(process.env.CHAIN_ID), process.env.ROLLUP_ADDRESS.toLowerCase()] + await db.none('INSERT INTO node_identity(singleton,chain_id,rollup) VALUES(true,$1,$2) ON CONFLICT DO NOTHING', identity) + const bound = await db.one('SELECT chain_id,rollup FROM node_identity WHERE singleton') + if (bound.chain_id !== identity[0] || bound.rollup !== identity[1]) throw new Error('Database belongs to another chain/Rollup; use a new volume') + const chain = new Chain(process.env.RPC_URL, process.env.ROLLUP_ADDRESS, process.env.CHAIN_ID) + await chain.snapshot() + const app = express() + app.disable('x-powered-by') + app.use(express.json({ limit: '256kb', strict: true })) + let active = false, subscription, streamr, server + const state = { streamr: process.env.STREAM_ID ? 'connecting' : 'disabled' } + async function accept(items, source) { + if (active) { const e = new Error('ingestion busy; retry'); e.status = 429; throw e } + active = true + try { return await ingest(db, chain, items, source, Number(process.env.MAX_BATCH_ACTIONS || 1000)) } + finally { active = false } + } + app.get('/health', async (_req, res) => { + try { await db.one('SELECT 1 AS ok'); res.json({ status: 'ok', chainId: identity[0], rollup: identity[1], ...state }) } + catch { res.status(503).json({ status: 'database unavailable' }) } + }) + app.post('/batch-payloads', async (req, res) => { + try { res.status(202).json(await accept(req.body, 'https')) } + catch (error) { + const status = error instanceof InvalidPayload ? 400 : error.status || 503 + res.status(status).json({ error: status === 503 ? 'validation unavailable; retry later' : error.message }) } - - return db -} - -initializeDatabase().then(async db => { - - events.setMaxListeners(20) - const app = express() - const port = process.env.STREAMR_CLIENT_PORT || 3000 - - app.get('/health', (_req, res) => { - res.status(200).json({ status: 'ok' }) - }) - - let sub = await streamr.subscribe(STREAM_ID, data => { - if ( - data && - Array.isArray(data) - ) { - saveGossipMessage(db, data) - } else if (data) { - console.log("Received message:", data) - } else { - console.warn("⚠️ Received invalid message format:", data) - } - }) - - console.log(`Subscribed to stream: ${sub.streamPartId}`) - - sub.on('error', (err) => { - console.error('Subscription error:', err) - }) - - // Clean up subscription on process exit - process.on('SIGINT', async () => { + }) + app.use((error, _req, res, _next) => res.status(error.status === 413 ? 413 : 400).json({ error: 'invalid JSON or request too large' })) + server = app.listen(Number(process.env.PORT || 3000), process.env.BIND_ADDRESS || '0.0.0.0', () => console.log('HTTP ingestion listening')) + // The proving worker runs in a separate container without a public listener. + if (process.env.STREAM_ID) { + const { StreamrClient } = require('@streamr/sdk') + streamr = new StreamrClient(process.env.STREAMR_PRIVATE_KEY ? { auth: { privateKey: process.env.STREAMR_PRIVATE_KEY } } : {}) + // HTTP and the scheduler remain available while Streamr reconnects. + ;(async () => { + while (!subscription) { try { - await sub.unsubscribe() - process.exit(0) - } catch (err) { - console.error('Error during cleanup:', err) - process.exit(1) - } - }) - - app.listen(port, () => { - console.log(`Server is running on port ${port}`) - }) -}) \ No newline at end of file + subscription = await streamr.subscribe(process.env.STREAM_ID, async data => { + try { const result = await accept(data, 'streamr'); console.log(`Streamr accepted ${result.accepted} actions`) } + catch (error) { console.warn('Streamr batch not queued:', error instanceof InvalidPayload ? error.message : 'temporarily unavailable') } + }) + state.streamr = 'subscribed' + subscription.on('error', () => { state.streamr = 'error'; console.warn('Streamr subscription error') }) + } catch { state.streamr = 'retrying'; await new Promise(resolve => setTimeout(resolve, 30000)) } + } + })() + } + const stop = async () => { + console.log('Stopping node') + server.close() + if (streamr) await streamr.destroy() + pgp.end() + process.exit(0) + } + process.on('SIGTERM', stop); process.on('SIGINT', stop) +} +main().catch(() => { console.error('Node startup failed; check configuration, database, chain and Rollup deployment'); process.exit(1) }) diff --git a/streamr-client/package-lock.json b/streamr-client/package-lock.json index 2b8e290..1d1f0c5 100644 --- a/streamr-client/package-lock.json +++ b/streamr-client/package-lock.json @@ -9,39 +9,20 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@streamr/sdk": "^103.1.0", + "@noble/curves": "1.9.7", + "@streamr/sdk": "103.4.0", "dotenv": "^17.2.3", + "ethers": "6.17.0", "express": "^5.1.0", "pg-promise": "^12.2.0" } }, "node_modules/@adraffy/ens-normalize": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", - "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz", - "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==", - "license": "MIT", - "dependencies": { - "core-js-pure": "^3.43.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -62,10 +43,10 @@ } }, "node_modules/@isaacs/fs-minipass/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -167,47 +148,47 @@ } }, "node_modules/@streamr/autocertifier-client": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/autocertifier-client/-/autocertifier-client-103.1.2.tgz", - "integrity": "sha512-Hf50kGiDXJJOKx2/3e7pXya/OAB9U37ki+NKefeOa5vQQK8VaieOZ8Pu15j5O5dinMDOmymolmnp2lNUJj06fQ==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/autocertifier-client/-/autocertifier-client-103.4.0.tgz", + "integrity": "sha512-Cj0v9LumN6y7+m1/vX2isBRShEifFioS9vEooeGwzZZgWcc7L3dVvTeivo9dLQmJaG0u3tgkSzeRiPUk1eM7OQ==", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/utils": "103.1.2", + "@streamr/utils": "103.4.0", "eventemitter3": "^5.0.0", - "node-forge": "^1.3.1" + "node-forge": "^1.3.2" } }, "node_modules/@streamr/cdn-location": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/cdn-location/-/cdn-location-103.1.2.tgz", - "integrity": "sha512-L9y7cYYsJN0KQ1vr7w5+OUJx7h2RO0bkU0KgqSZviLuUw9PnZp4P7gyLsMEyJP1KlJwaAz7UEc4VCVn/DDTxmw==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/cdn-location/-/cdn-location-103.4.0.tgz", + "integrity": "sha512-NYL8sHEuzAUunP76QU5ZmQ8LvySC82YmQIHrVpqZqTR80EMgdj+gBAR7vz6CiYjzM/tW1/lB9v0cJpeayPAeFA==", "license": "Apache-2.0", "dependencies": { - "@streamr/utils": "103.1.2", + "@streamr/utils": "103.4.0", "haversine": "^1.1.1" } }, "node_modules/@streamr/config": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/@streamr/config/-/config-5.9.2.tgz", - "integrity": "sha512-ZLt9kMHYqQjyZkRrQCNWLLzXc6+pON2ktf2bHTEjIE5occXsAo8JsWk3kMP3AiYzluy8lXDB/+F+t/397YsKvg==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/@streamr/config/-/config-5.9.3.tgz", + "integrity": "sha512-oLURlqm09zHVD0PYb/bUMp2eAcJ04PJrOvv8hl52XPzlhj93aygwv90tVaz04A5NiNF75vjSUFImEVEdG2Zl7g==", "license": "STREAMR NETWORK OPEN SOURCE LICENSE" }, "node_modules/@streamr/dht": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/dht/-/dht-103.1.2.tgz", - "integrity": "sha512-d7ztpk1kfbK0ziHjfCXdqavAHOy77RaBAgnsTLvH4rDz4wV4tqLMC+hp/BieGtYlLxbgnFZivU4PD3ub7TL+Hg==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/dht/-/dht-103.4.0.tgz", + "integrity": "sha512-FRSQg/FFo/0NzL/LiY/FFZM/itvdpyDaKGpHR/EhSgUBoRjJ3XjtdybdyrsHq4xMQIszfPnN0r78R3k33ok2fg==", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@js-sdsl/ordered-map": "^4.4.2", "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/autocertifier-client": "103.1.2", - "@streamr/cdn-location": "103.1.2", - "@streamr/geoip-location": "103.1.2", - "@streamr/proto-rpc": "103.1.2", - "@streamr/utils": "103.1.2", + "@streamr/autocertifier-client": "103.4.0", + "@streamr/cdn-location": "103.4.0", + "@streamr/geoip-location": "103.4.0", + "@streamr/proto-rpc": "103.4.0", + "@streamr/utils": "103.4.0", "eventemitter3": "^5.0.0", "heap": "^0.2.6", "ipaddr.js": "^2.0.1", @@ -215,6 +196,7 @@ "lodash": "^4.17.21", "lru-cache": "^11.2.2", "node-datachannel": "^0.27.0", + "timers-browserify": "^2.0.12", "uuid": "^11.1.0", "websocket": "^1.0.34", "ws": "^8.18.3" @@ -225,12 +207,12 @@ } }, "node_modules/@streamr/geoip-location": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/geoip-location/-/geoip-location-103.1.2.tgz", - "integrity": "sha512-3dLUCGYgO4E2UmVxOyMyLzjvWFjzCueTYmqBbYbjpAHKFpqPv9Aw3skADTo3WA/8yzq8BLGaBC1eNXZvX+fKLQ==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/geoip-location/-/geoip-location-103.4.0.tgz", + "integrity": "sha512-SxhhfEvfcfTN0rsKnmVkFTnLq0cGSQVg0BM0tmYqpotTKqF/Q1aWmrmEPjfVQzAz2LCe67w6xBf+W6b2pZfHRA==", "license": "Apache-2.0", "dependencies": { - "@streamr/utils": "103.1.2", + "@streamr/utils": "103.4.0", "eventemitter3": "^5.0.0", "long-timeout": "^0.1.1", "mmdb-lib": "^3.0.1", @@ -239,20 +221,20 @@ } }, "node_modules/@streamr/network-contracts": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@streamr/network-contracts/-/network-contracts-9.1.0.tgz", - "integrity": "sha512-OW+eJQOpcColPvPjJQ0Udaee4AlN2wTZwK7Hj6Vq59qdGWYv9FSxv+kSrYAbBvjuHvTkjH8UkbI1kYVPX2pm7A==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@streamr/network-contracts/-/network-contracts-9.2.0.tgz", + "integrity": "sha512-g3PhbTn/eKE2t1jFgKthlkjMECDtlxCZK/T2UTgXF9g2TeGWM06pQq5I4UXttbdendLIXXTaDAK0Bu06Cs3m4w==", "license": "STREAMR NETWORK OPEN SOURCE LICENSE" }, "node_modules/@streamr/proto-rpc": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/proto-rpc/-/proto-rpc-103.1.2.tgz", - "integrity": "sha512-SyStd/qctD7+5Y2IE76/BtYwoaG3aVY7qmdZnqZTTZSqyKBbSQfd8piCa6p4cd5PlRgnHGL5kCjTxJ+aIzuQTg==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/proto-rpc/-/proto-rpc-103.4.0.tgz", + "integrity": "sha512-E1WAo8/rNp5h/PmblHs2qC9NQl0BSubKlBnJ6zcVHlYQ32lfqc2q8IMF5utVSVa9aIOkbEWXe4GGdVZ9kkepEA==", "license": "(Apache-2.0 AND BSD-3-Clause)", "dependencies": { "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/utils": "103.1.2", + "@streamr/utils": "103.4.0", "eventemitter3": "^5.0.0", "lodash": "^4.17.21", "uuid": "^11.1.0" @@ -263,23 +245,20 @@ } }, "node_modules/@streamr/sdk": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/sdk/-/sdk-103.1.2.tgz", - "integrity": "sha512-CGN2U0Zt2c+NZ3FGNEtmL0s15BjwxlHVIsLz7+ki11eNbFcYksEjU5A3/OY+fjAWVPmZaqLnClV/7eBbFSP+sQ==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/sdk/-/sdk-103.4.0.tgz", + "integrity": "sha512-4ufevQChKYRojgCe++2EAgw1n8MSMlmiDBiQH7TVDljvJQz/I4hjaqoFBzN0sMSsIIog3D3S0eIZbxPtLHef3Q==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.28.4", - "@babel/runtime-corejs3": "^7.28.4", "@noble/post-quantum": "^0.4.1", "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/config": "^5.9.2", - "@streamr/dht": "103.1.2", - "@streamr/network-contracts": "^9.1.0", - "@streamr/proto-rpc": "103.1.2", - "@streamr/trackerless-network": "103.1.2", - "@streamr/utils": "103.1.2", - "core-js": "^3.46.0", + "@streamr/config": "^5.9.3", + "@streamr/dht": "103.4.0", + "@streamr/network-contracts": "^9.2.0", + "@streamr/proto-rpc": "103.4.0", + "@streamr/trackerless-network": "103.4.0", + "@streamr/utils": "103.4.0", "env-paths": "^2.2.1", "ethers": "^6.13.0", "eventemitter3": "^5.0.0", @@ -289,31 +268,49 @@ "p-limit": "^3.1.0", "p-memoize": "4.0.4", "p-throttle": "4.1.1", + "readable-stream": "^4.7.0", "reflect-metadata": "^0.2.1", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", + "timers-browserify": "^2.0.12", "ts-essentials": "^10.1.1", "ts-toolbelt": "^9.6.0", "tsyringe": "^4.10.0", "uuid": "^11.1.0", - "zod": "^4.1.12" + "zod": "^4.1.13" }, "optionalDependencies": { "bufferutil": "^4.0.9", "utf-8-validate": "^6.0.5" } }, + "node_modules/@streamr/sdk/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@streamr/trackerless-network": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/trackerless-network/-/trackerless-network-103.1.2.tgz", - "integrity": "sha512-oTRsSWDjaZdkYvyiqAWyz69FDiOXdlJoV8YD8H/wrtHO4nYfYnhVhhInJNRGTU4NeYLzrmDs+VAx+eFyTGGFtQ==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/trackerless-network/-/trackerless-network-103.4.0.tgz", + "integrity": "sha512-oCgglXEYfNGDtE6jyIBNfVcjFNS5c4yNFTYUuwc3TfGYr7gLmVtCKWIxlwcCNvoJksoosTpiOdEi25Yn2e1fJA==", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/dht": "103.1.2", - "@streamr/proto-rpc": "103.1.2", - "@streamr/utils": "103.1.2", + "@streamr/dht": "103.4.0", + "@streamr/proto-rpc": "103.4.0", + "@streamr/utils": "103.4.0", "eventemitter3": "^5.0.0", "lodash": "^4.17.21", "ts-essentials": "^10.1.1", @@ -322,21 +319,58 @@ } }, "node_modules/@streamr/utils": { - "version": "103.1.2", - "resolved": "https://registry.npmjs.org/@streamr/utils/-/utils-103.1.2.tgz", - "integrity": "sha512-m9U1EaxfuxAhIyqA59lCNOFd3fH2S8KoTgV9OopTZatMCMIwAgfe1XC1YJ8mLu/XhRSuAnlvjE2QDN4ukAichg==", + "version": "103.4.0", + "resolved": "https://registry.npmjs.org/@streamr/utils/-/utils-103.4.0.tgz", + "integrity": "sha512-dhgUJZCC7Fgn6oSFpQ+7fpBVsutm7bMvdmeaKPcePRpwyKYPTsJo3wcqgqFo0PEElbUR2Ra8I+FVn5Ebpgij2A==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.0.1", "@noble/post-quantum": "^0.4.1", + "asn1.js": "^5.4.1", + "browserify-aes": "^1.2.0", + "buffer": "^6.0.3", + "buffer-shim": "^1.0.1", "eventemitter3": "^5.0.0", + "hash-base": "^3.1.2", "lodash": "^4.17.21", + "path-browserify": "^1.0.1", "pino": "^10.1.0", "pino-pretty": "^13.1.2", + "public-encrypt": "^4.0.3", + "readable-stream": "^4.7.0", "secp256k1": "^5.0.1", "sha3": "^2.1.4" } }, + "node_modules/@streamr/utils/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@streamr/utils/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -363,6 +397,18 @@ "license": "ISC", "optional": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -454,6 +500,18 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-options": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.3.tgz", @@ -472,6 +530,21 @@ "node": ">=8.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -544,27 +617,40 @@ } }, "node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.1.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -574,9 +660,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "optional": true, "dependencies": { @@ -590,6 +676,40 @@ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", "license": "MIT" }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-rsa/node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "license": "MIT" + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -614,6 +734,21 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-shim/-/buffer-shim-1.0.1.tgz", + "integrity": "sha512-VG1oTE6Ecr9h/Gx3XOXue0F1vQaQlkBd3tGAzpL7Fsu+8f1Jbtk5ORPkrVLA/ADBTQ08bcPt3HanaZ7tUfJqMg==", + "license": "ISC", + "peerDependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "license": "MIT" + }, "node_modules/bufferutil": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", @@ -679,34 +814,6 @@ "node": ">=10" } }, - "node_modules/cacache/node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "optional": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/cacache/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -714,6 +821,24 @@ "license": "ISC", "optional": true }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -752,6 +877,20 @@ "node": ">=10" } }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -832,26 +971,37 @@ "node": ">=6.6.0" } }, - "node_modules/core-js": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", - "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", - "hasInstallScript": true, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" } }, - "node_modules/core-js-pure": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.47.0.tgz", - "integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==", - "hasInstallScript": true, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" } }, "node_modules/d": { @@ -917,6 +1067,23 @@ "node": ">=4.0.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -1072,9 +1239,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1154,9 +1321,9 @@ } }, "node_modules/ethers": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", - "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", "funding": [ { "type": "individual", @@ -1169,13 +1336,13 @@ ], "license": "MIT", "dependencies": { - "@adraffy/ens-normalize": "1.10.1", + "@adraffy/ens-normalize": "1.11.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.2", "@types/node": "22.7.5", "aes-js": "4.0.0-beta.5", "tslib": "2.7.0", - "ws": "8.17.1" + "ws": "8.21.0" }, "engines": { "node": ">=14.0.0" @@ -1205,27 +1372,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/ethers/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -1236,12 +1382,40 @@ "es5-ext": "~0.10.14" } }, - "node_modules/eventemitter3": { + "node_modules/event-target-shim": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1304,9 +1478,9 @@ } }, "node_modules/fast-copy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.0.tgz", - "integrity": "sha512-/oA0gx1xyXE9R2YlV4FXwZJXngFdm9Du0zN8FhY38jnLkhp1u35h6bCyKgRhlsA6C9I+1vfXE4KISdt7xc6M9w==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.3.1.tgz", + "integrity": "sha512-CRzQaTFOFUs6PuhsAq7lk9Zqr995soJBcFNZlfJS/5keTSl1awREV9dZW4xS2nX4pWbhIIR0xp4h+J8PQxCkvg==", "license": "MIT" }, "node_modules/fast-safe-stringify": { @@ -1342,6 +1516,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1499,6 +1688,18 @@ "license": "ISC", "optional": true }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1511,6 +1712,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -1518,6 +1734,63 @@ "license": "ISC", "optional": true }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/hash-base/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/hash-base/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hash-base/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/hash-base/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/hash-base/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -1529,9 +1802,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1570,9 +1843,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause", "optional": true }, @@ -1636,9 +1909,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1729,9 +2002,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "license": "MIT", "optional": true, "engines": { @@ -1739,14 +2012,26 @@ } }, "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", "license": "MIT", "engines": { "node": ">= 10" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1770,12 +2055,33 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "license": "MIT" }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1802,9 +2108,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/long-timeout": { @@ -1814,9 +2120,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1901,6 +2207,17 @@ "node": ">= 0.4" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -1981,9 +2298,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, "dependencies": { @@ -2128,9 +2445,9 @@ "license": "MIT" }, "node_modules/mmdb-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.1.tgz", - "integrity": "sha512-dyAyMR+cRykZd1mw5altC9f4vKpCsuywPwo8l/L5fKqDay2zmqT0mF/BvUoXnQiqGn+nceO914rkPKJoyFnGxA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.3.tgz", + "integrity": "sha512-xQPoBXcNjjHiOvOraFBKtA++uNWF6aCVHL9dRKFXEov8eI3QJwtgiw3qApsonFT5SpoqsEVISUTg3HIDs2DiXw==", "license": "MIT", "engines": { "node": ">=10", @@ -2196,9 +2513,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.1.tgz", + "integrity": "sha512-w5/MfWaeMrpR/5ba6fZyAUTLSuE6aYz7H/3cYrqwZEmf5eQS3IaaLgJt4MBv7HglGFWmV22K8h4U18RJQmw/ow==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -2240,41 +2557,6 @@ "node-gyp-build-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/node-gyp/node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "optional": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -2468,6 +2750,33 @@ "node": ">=6" } }, + "node_modules/parse-asn1": { + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.10.tgz", + "integrity": "sha512-gBlTKLbYnAJN9WjmawVOMfaGvVoy4lX0LH18t7ccwtEG9RgAa4ETf2eu3d2J4e/5SoCjCms0A0UF4tTw/Eyn6Q==", + "license": "ISC", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.7", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse-asn1/node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2477,6 +2786,12 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2488,15 +2803,32 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, + "node_modules/pbkdf2": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.7.tgz", + "integrity": "sha512-nS4mvFgVwUrecPTRdpdseM2fwpdfnX0/HibA8DgBRQtihUAzHQYJFU2l+OlEtiXtpqkrMvCPDwyXMUnASDt6Qw==", + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -2637,40 +2969,40 @@ } }, "node_modules/pino": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", - "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", "license": "MIT", "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", + "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", + "real-require": "^1.0.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", "dependencies": { "split2": "^4.0.0" } }, "node_modules/pino-pretty": { - "version": "13.1.3", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", - "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.2.0.tgz", + "integrity": "sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==", "license": "MIT", "dependencies": { "colorette": "^2.0.7", @@ -2680,32 +3012,41 @@ "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", - "on-exit-leak-free": "^2.1.0", + "on-exit-leak-free": "^2.1.2", "pino-abstract-transport": "^3.0.0", - "pump": "^3.0.0", - "secure-json-parse": "^4.0.0", - "sonic-boom": "^4.0.1", - "strip-json-comments": "^5.0.2" + "pump": "^3.0.3", + "secure-json-parse": "^4.1.0", + "sonic-boom": "^5.0.0", + "strip-json-comments": "^5.0.3" }, "bin": { "pino-pretty": "bin.js" } }, - "node_modules/pino-pretty/node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "node_modules/pino-pretty/node_modules/sonic-boom": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-5.0.2.tgz", + "integrity": "sha512-/JEruzo6yhNVhsLblTRchgTQHIt7SsdJP0YyrfWaNKjIOQDfrdZv9gwYraYCAsmKV39/LAdV31FLL5NCDpoL/A==", "license": "MIT", "dependencies": { - "split2": "^4.0.0" + "atomic-sleep": "^1.0.0" } }, "node_modules/pino-std-serializers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", - "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2771,10 +3112,25 @@ "node": ">=10" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", "funding": [ { "type": "github", @@ -2809,9 +3165,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -2819,6 +3175,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-addr/node_modules/ipaddr.js": { @@ -2830,6 +3190,20 @@ "node": ">= 0.10" } }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -2841,12 +3215,13 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", + "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -2933,13 +3308,10 @@ } }, "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" }, "node_modules/reflect-metadata": { "version": "0.2.2", @@ -2974,6 +3346,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3026,9 +3411,9 @@ "license": "MIT" }, "node_modules/secp256k1": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-5.0.1.tgz", - "integrity": "sha512-lDFs9AAIaWP9UCdtWrotXWWF9t8PWgQDcxqgAnpM9rMqxb3Oaq2J0thzPVSxBwdJgyQtkU/sYtFtbM1RSt/iYA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-5.0.2.tgz", + "integrity": "sha512-HwMOXeWjr1UvBNYt9S+w9bMuuyUOrPFQ8CWd23CGai/8vbgtUKS2ITngFOzMhaPZQAPxTb/f9alLou9mPnNV2Q==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3041,9 +3426,9 @@ } }, "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz", + "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==", "funding": [ { "type": "github", @@ -3112,12 +3497,55 @@ "license": "ISC", "optional": true }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sha3": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/sha3/-/sha3-2.1.4.tgz", @@ -3128,14 +3556,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -3147,13 +3575,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -3293,9 +3721,9 @@ } }, "node_modules/sonic-boom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", - "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" @@ -3349,44 +3777,12 @@ } } }, - "node_modules/sqlite3/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, "node_modules/sqlite3/node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, - "node_modules/sqlite3/node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sqlite3/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -3459,9 +3855,9 @@ } }, "node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -3518,10 +3914,10 @@ } }, "node_modules/tar/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -3539,12 +3935,41 @@ } }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/timers-browserify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", + "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.4" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "license": "MIT", "dependencies": { - "real-require": "^0.2.0" + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/toidentifier": { @@ -3557,9 +3982,9 @@ } }, "node_modules/ts-essentials": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", - "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.2.1.tgz", + "integrity": "sha512-+Id1fRkuir+CsgK2x04/icS2b4V1hQmq7ObzIrDjhN0ozfRYivnP7aaKMVJfLApQm0trjR39A6NIMVchiB9Erw==", "license": "MIT", "peerDependencies": { "typescript": ">=4.5.0" @@ -3619,17 +4044,48 @@ "license": "ISC" }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/typedarray-to-buffer": { @@ -3697,9 +4153,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -3779,6 +4235,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -3796,9 +4273,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3857,9 +4334,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/streamr-client/package.json b/streamr-client/package.json index 84cb6a8..7e20006 100644 --- a/streamr-client/package.json +++ b/streamr-client/package.json @@ -5,14 +5,19 @@ "main": "index.js", "scripts": { "start": "node ./index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test test/*.test.js" }, "author": "Godwin", "license": "MIT", "dependencies": { - "@streamr/sdk": "^103.1.0", + "@noble/curves": "1.9.7", + "@streamr/sdk": "103.4.0", + "dotenv": "^17.2.3", + "ethers": "6.17.0", "express": "^5.1.0", - "pg-promise": "^12.2.0", - "dotenv": "^17.2.3" + "pg-promise": "^12.2.0" + }, + "overrides": { + "tar": "7.5.22" } } diff --git a/streamr-client/test/database.test.js b/streamr-client/test/database.test.js new file mode 100644 index 0000000..414b9a1 --- /dev/null +++ b/streamr-client/test/database.test.js @@ -0,0 +1,22 @@ +const test = require('node:test'), assert = require('node:assert/strict') +const { ed25519 } = require('@noble/curves/ed25519') +const { ingest } = require('../validation') +const MigrationManager = require('../db/migration-manager') +test('PostgreSQL migrations, atomic validation, duplicate races and conflicting nonce rollback', {skip:!process.env.TEST_DATABASE_URL}, async()=>{ + const pgp=require('pg-promise')(), db=pgp(process.env.TEST_DATABASE_URL) + try { + await db.task(c=>new MigrationManager(c,require('path').join(__dirname,'../db/migrations')).runMigrations()) + await db.none('TRUNCATE m3ter_payloads') + const secret=new Uint8Array(32).fill(45),key=Buffer.from(ed25519.getPublicKey(secret)).toString('hex') + function action(n,energy=1000){const b=Buffer.alloc(8);b.writeUInt32BE(n);b.writeUInt32BE(energy,4);return {m3ter_id:7,message:b.toString('hex')+Buffer.from(ed25519.sign(b,secret)).toString('hex')}} + const chain={snapshot:async()=>({meter:async()=>({key,nonce:0})})} + const results=await Promise.all([ingest(db,chain,[action(1)],'https'),ingest(db,chain,[action(1)],'streamr')]) + assert.equal(results.reduce((s,r)=>s+r.accepted,0),1) + assert.equal((await db.one('SELECT count(*)::integer AS n FROM m3ter_payloads')).n,1) + await assert.rejects(ingest(db,chain,[action(2),action(1,2000)],'https')) + assert.equal((await db.one('SELECT count(*)::integer AS n FROM m3ter_payloads')).n,1) + const invalid=action(2);invalid.message=invalid.message.slice(0,16)+'00'.repeat(64) + await assert.rejects(ingest(db,chain,[action(3),invalid],'streamr')) + assert.equal((await db.one('SELECT count(*)::integer AS n FROM m3ter_payloads')).n,1) + } finally {pgp.end()} +}) diff --git a/streamr-client/test/validation.test.js b/streamr-client/test/validation.test.js new file mode 100644 index 0000000..4ab0b18 --- /dev/null +++ b/streamr-client/test/validation.test.js @@ -0,0 +1,64 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const { ed25519 } = require('@noble/curves/ed25519') +const { decode, authenticate, ingest } = require('../validation') +const { prepare, unresolved } = require('../worker') +const fs = require('node:fs'), os = require('node:os'), path = require('node:path') +const secret = new Uint8Array(32).fill(42), key = Buffer.from(ed25519.getPublicKey(secret)).toString('hex') +function signed(id, nonce, energy = 600) { + const message = Buffer.alloc(8); message.writeUInt32BE(nonce); message.writeUInt32BE(energy, 4) + return { m3ter_id: id, message: message.toString('hex') + Buffer.from(ed25519.sign(message, secret)).toString('hex') } +} +test('wire compatibility, exact lengths, signed fields and capacity', () => { + const action = decode({ ...signed(22, 1), nonce: 999, energy: 999 }) + assert.equal(action.nonce, 1); assert.equal(action.energy, 600); authenticate(action, key) + for (const value of [signed(-1,1), signed(17179869184,1), signed(1,0), signed(1,16777216), {m3ter_id:1,message:signed(1,1).message+'00'}]) assert.throws(()=>decode(value)) + const tampered = { ...action, message: '00000001000007d0' } + assert.throws(()=>authenticate(tampered,key)); assert.throws(()=>authenticate(action,'00'.repeat(32))) + assert.throws(()=>authenticate({...action,signature:'00'.repeat(64)},key)) +}) +test('all actions are validated before any database writes', async () => { + let writes = 0 + const db = { tx: async () => { writes++ } } + const chain = { snapshot: async () => ({meter: async () => ({key,nonce:0})}) } + const bad = signed(23,1); bad.message = bad.message.slice(0,16) + '00'.repeat(64) + await assert.rejects(ingest(db,chain,[signed(22,1),bad],'https')); assert.equal(writes,0) + await assert.rejects(ingest(db,chain,[signed(22,1),signed(22,1,1000)],'https')); assert.equal(writes,0) + await assert.rejects(ingest(db,{snapshot:async()=>({meter:async()=>({key,nonce:1})})},[signed(22,1)],'https')) +}) +test('nonce gaps do not block other meters; consecutive actions aggregate before flooring', async () => { + const rows = [signed(1,2),signed(2,1),signed(2,2)].map((x,i)=>({...decode(x),id:i+1})) + const updates=[] + await prepare({any:async()=>rows,none:async(_,args)=>updates.push(args)}, {snapshot:async()=>({meter:async()=>({key,nonce:0,account:0n})})}) + assert.deepEqual(updates.map(x=>x[1]), ['waiting','ready','ready']) + assert.deepEqual(updates.map(x=>x[3]), [false,false,false]) +}) +test('paid-attempt marker blocks fresh proving across restart until completion', () => { + const root=fs.mkdtempSync(path.join(os.tmpdir(),'prover-test-'));const dir=path.join(root,'batch');fs.mkdirSync(dir) + assert.deepEqual(unresolved(root),[]);fs.writeFileSync(path.join(dir,'paid-attempt.json'),'{}') + assert.deepEqual(unresolved(root),['batch']);fs.writeFileSync(path.join(dir,'complete.json'),'{}');assert.deepEqual(unresolved(root),[]) + fs.rmSync(root,{recursive:true}) +}) +module.exports={signed,key} +test('scheduler reduces only unpaid local-admission failures and stops once a paid marker exists', async () => { + const { startWorker } = require('../worker') + const root=fs.mkdtempSync(path.join(os.tmpdir(),'scheduler-test-')) + const binary=path.join(root,'fake-host') + fs.writeFileSync(binary,`#!/usr/bin/env node +const fs=require('fs'),path=require('path');const out=process.env.OUTPUT_DIR; +fs.writeFileSync(path.join(out,'actions'),process.env.MAX_ACTIONS); +fs.writeFileSync(path.join(out,Number(process.env.MAX_ACTIONS)>1?'reduce-batch.json':'paid-attempt.json'),'{}');process.exit(1); +`,{mode:0o700}) + const saved={...process.env} + Object.assign(process.env,{BATCH_DIR:path.join(root,'batches'),PROVER_BINARY:binary,PROVING_ENABLED:'true',PROVE_INTERVAL_SECONDS:'30',MAX_ACTIONS:'4'}) + const worker=startWorker({any:async()=>[],one:async()=>({count:1})},{snapshot:async()=>({})},{}) + try { + for(let i=0;i<100;i++){ + const dirs=fs.existsSync(path.join(root,'batches'))?fs.readdirSync(path.join(root,'batches')):[] + if(dirs.some(d=>fs.existsSync(path.join(root,'batches',d,'paid-attempt.json'))))break + await new Promise(r=>setTimeout(r,100)) + } + const values=fs.readdirSync(path.join(root,'batches')).map(d=>Number(fs.readFileSync(path.join(root,'batches',d,'actions'),'utf8'))).sort((a,b)=>b-a) + assert.deepEqual(values,[4,2,1]) + } finally {await worker.stop();for(const k of Object.keys(process.env))if(!(k in saved))delete process.env[k];Object.assign(process.env,saved);fs.rmSync(root,{recursive:true})} +}) diff --git a/streamr-client/util/decode.js b/streamr-client/util/decode.js deleted file mode 100644 index 5d360dc..0000000 --- a/streamr-client/util/decode.js +++ /dev/null @@ -1,26 +0,0 @@ - -function decodePayload(msg) { - const buf = Buffer.from(msg, 'hex'); - if (buf.length < 72) { - throw new Error("Payload too short. Must be at least 72 bytes"); - } - const nonce = buf.readUInt32BE(0); - const energy = buf.readUInt32BE(4); - - const message = buf.subarray(0, 8).toString("hex"); - const signature = buf.subarray(8, 72).toString("hex"); - - return { - nonce, - energy, - message, - signature, - }; -} - -module.exports = { - decodePayload, -}; - - - diff --git a/streamr-client/util/streamr-client.js b/streamr-client/util/streamr-client.js deleted file mode 100644 index 98a73fe..0000000 --- a/streamr-client/util/streamr-client.js +++ /dev/null @@ -1,24 +0,0 @@ - -const { StreamrClient } = require('@streamr/sdk'); - -const { STREAM_ID } = process.env; - -if (!STREAM_ID) { - throw new Error("Missing STREAM_ID in env"); -} - -// Initialize the client with an Ethereum account -const streamr = new StreamrClient({ - // environment: process.env.STREAMR_ENV == "live" ? "polygon" : "polygonAmoy", -}); - -async function ensureStream() { - const stream = await streamr.getOrCreateStream({ id: STREAM_ID }); - return stream; -} - -module.exports = { - streamr, - ensureStream, - STREAM_ID -}; \ No newline at end of file diff --git a/streamr-client/validation.js b/streamr-client/validation.js new file mode 100644 index 0000000..8274292 --- /dev/null +++ b/streamr-client/validation.js @@ -0,0 +1,96 @@ +'use strict' +const { ed25519 } = require('@noble/curves/ed25519') +const { Interface } = require('ethers') +const abi = new Interface([ + 'function publicKey(uint64) view returns (bytes32)', + 'function words(uint256) view returns (uint256)', + 'function wordCount() view returns (uint256)' +]) +class InvalidPayload extends Error {} +function hex(value, bytes) { + if (typeof value !== 'string' || !new RegExp(`^(?:0x)?[a-fA-F0-9]{${bytes * 2}}$`).test(value)) { + throw new InvalidPayload(`expected exactly ${bytes} bytes of hex`) + } + return value.replace(/^0x/, '').toLowerCase() +} +function decode(item) { + if (!item || !Number.isSafeInteger(item.m3ter_id) || item.m3ter_id < 0 || item.m3ter_id > 17179869183) { + throw new InvalidPayload('m3ter_id must be an integer in 0..17179869183') + } + const packed = hex(item.message, 72) + const bytes = Buffer.from(packed, 'hex') + const nonce = bytes.readUInt32BE(0) + if (nonce < 1 || nonce > 16777215) throw new InvalidPayload('nonce must be in 1..16777215') + return { m3ter_id: item.m3ter_id, nonce, energy: bytes.readUInt32BE(4), + message: packed.slice(0, 16), signature: packed.slice(16) } +} +function authenticate(action, key) { + const raw = hex(key, 32) + try { + if (/^0+$/.test(raw) || !ed25519.verify(action.signature, action.message, raw, { zip215: false })) { + throw new Error('signature') + } + } catch { throw new InvalidPayload(`invalid signature or unregistered key for meter ${action.m3ter_id}`) } +} +class Chain { + constructor(url, rollup, chainId) { this.url = url; this.rollup = rollup; this.chainId = chainId } + async rpc(method, params) { + let response + try { + response = await fetch(this.url, { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) }) + } catch { throw new Error('Ethereum RPC unavailable') } + if (!response.ok) throw new Error('Ethereum RPC HTTP failure') + const data = await response.json() + if (data.error || data.result == null) throw new Error(`Ethereum RPC ${method} failed`) + return data.result + } + async view(name, args, tag) { + const result = await this.rpc('eth_call', [{ to: this.rollup, data: abi.encodeFunctionData(name, args) }, tag]) + return abi.decodeFunctionResult(name, result)[0] + } + async snapshot(finality = 'latest') { + if (BigInt(await this.rpc('eth_chainId', [])) !== BigInt(this.chainId)) throw new Error('wrong Ethereum chain') + const block = await this.rpc('eth_getBlockByNumber', [finality, false]) + const tag = { blockHash: block.hash, requireCanonical: true } + const count = await this.view('wordCount', [], tag) + const words = new Map(), keys = new Map() + return { block, meter: async id => { + const slot = Math.floor(id / 4) + if (!words.has(slot)) words.set(slot, BigInt(slot) < count ? await this.view('words', [slot], tag) : 0n) + if (!keys.has(id)) keys.set(id, await this.view('publicKey', [id], tag)) + const pair = words.get(slot) >> BigInt(64 * (id % 4)) + return { key: keys.get(id), account: pair & ((1n << 40n) - 1n), nonce: Number((pair >> 40n) & 0xffffffn) } + } } + } +} +async function ingest(db, chain, items, source, max = 1000) { + if (!Array.isArray(items) || !items.length || items.length > max) throw new InvalidPayload(`batch must contain 1..${max} actions`) + // Validate the entire batch before inserting anything. Ignore unsigned metadata. + const actions = items.map(decode), snapshot = await chain.snapshot() + const seen = new Map() + for (const action of actions) { + const state = await snapshot.meter(action.m3ter_id) + authenticate(action, state.key) + if (action.nonce <= state.nonce) throw new InvalidPayload(`nonce already consumed for meter ${action.m3ter_id}`) + const k = `${action.m3ter_id}:${action.nonce}` + if (seen.has(k) && seen.get(k).message !== action.message) throw new InvalidPayload('conflicting nonce in batch') + seen.set(k, action) + } + return db.tx(async tx => { + let inserted = 0 + for (const action of seen.values()) { + const row = await tx.oneOrNone(`INSERT INTO m3ter_payloads + (m3ter_id,nonce,energy,message,signature,is_verified,source,queue_status) + VALUES ($1,$2,$3,$4,$5,false,$6,'pending') ON CONFLICT (m3ter_id,nonce) DO NOTHING RETURNING id`, + [action.m3ter_id, action.nonce, action.energy, action.message, action.signature, source]) + if (row) inserted++ + else { + const prior = await tx.one('SELECT message,signature FROM m3ter_payloads WHERE m3ter_id=$1 AND nonce=$2', [action.m3ter_id, action.nonce]) + if (prior.message !== action.message || prior.signature !== action.signature) throw new InvalidPayload('conflicting action already queued for this nonce') + } + } + return { accepted: inserted, duplicates: actions.length - inserted } + }) +} +module.exports = { decode, authenticate, Chain, InvalidPayload, ingest } diff --git a/streamr-client/worker-main.js b/streamr-client/worker-main.js new file mode 100644 index 0000000..4d4eb41 --- /dev/null +++ b/streamr-client/worker-main.js @@ -0,0 +1,15 @@ +'use strict' +require('dotenv').config({ quiet: true }) +const pgp = require('pg-promise')() +const { Chain } = require('./validation') +const { startWorker } = require('./worker') +async function main() { + const db = pgp(process.env.DATABASE_URL) + const identity = await db.one('SELECT chain_id,rollup FROM node_identity WHERE singleton') + if (identity.chain_id !== process.env.CHAIN_ID || identity.rollup !== process.env.ROLLUP_ADDRESS.toLowerCase()) throw new Error('Database identity mismatch') + const chain = new Chain(process.env.RPC_URL, process.env.ROLLUP_ADDRESS, process.env.CHAIN_ID) + await chain.snapshot() + const worker = startWorker(db, chain, {}) + for (const signal of ['SIGTERM','SIGINT']) process.on(signal, async () => { await worker.stop(); pgp.end(); process.exit(0) }) +} +main().catch(()=>{console.error('Worker startup failed; check configuration, database and chain');process.exit(1)}) diff --git a/streamr-client/worker.js b/streamr-client/worker.js new file mode 100644 index 0000000..0137597 --- /dev/null +++ b/streamr-client/worker.js @@ -0,0 +1,108 @@ +'use strict' +const fs = require('node:fs') +const path = require('node:path') +const { spawn } = require('node:child_process') +const { authenticate } = require('./validation') + +// Refresh eligibility against one confirmed chain snapshot. A missing nonce waits; +// it cannot stall unrelated meters. Recheck invalidated signatures after rotation. +async function prepare(db, chain, limit = 10000) { + const snapshot = await chain.snapshot('finalized') + const rows = await db.any(`SELECT * FROM m3ter_payloads WHERE NOT is_verified + ORDER BY checked_at NULLS FIRST,m3ter_id,nonce LIMIT $1`, [limit]) + rows.sort((a,b) => Number(a.m3ter_id) - Number(b.m3ter_id) || Number(a.nonce) - Number(b.nonce)) + const states = new Map() + for (const row of rows) { + const id = Number(row.m3ter_id) + if (!states.has(id)) states.set(id, { ...await snapshot.meter(id), energy: 0n }) + const state = states.get(id) + let status = 'waiting', reason = 'nonce gap', consumed = false + if (Number(row.nonce) <= state.nonce && !state.advanced) { consumed = true; status = 'consumed'; reason = null } + else { + try { + authenticate({ ...row, m3ter_id: id }, state.key) + if (Number(row.nonce) === state.nonce + 1) { + const energy = state.energy + BigInt(row.energy) + if (state.account + energy / 1000n > (1n << 40n) - 1n) { status = 'waiting'; reason = 'account capacity exhausted' } + else { status = 'ready'; reason = null; state.nonce++; state.energy = energy; state.advanced = true } + } + } catch { status = 'waiting'; reason = 'signature does not match current key' } + } + await db.none('UPDATE m3ter_payloads SET queue_status=$2,reason=$3,is_verified=$4,checked_at=now() WHERE id=$1', [row.id, status, reason, consumed]) + } + return rows.length +} +function unresolved(root) { + if (!fs.existsSync(root)) return [] + return fs.readdirSync(root).filter(name => { + const dir = path.join(root, name) + return fs.existsSync(path.join(dir, 'paid-attempt.json')) && !fs.existsSync(path.join(dir, 'complete.json')) + }) +} +function startWorker(db, chain, state) { + const root = process.env.BATCH_DIR || '/data/batches' + fs.mkdirSync(root, { recursive: true }) + state = new Proxy(state, { set(object, name, value) { + object[name] = value + const file = path.join(path.dirname(root), 'status.json') + fs.writeFileSync(file + '.tmp', JSON.stringify({ ...object, updatedAt: new Date().toISOString() })) + fs.renameSync(file + '.tmp', file) + return true + } }) + let stopping = false, child, timer, activeTask = Promise.resolve() + const interval = Number(process.env.PROVE_INTERVAL_SECONDS || 3600) + if (!Number.isSafeInteger(interval) || interval < 30) throw new Error('PROVE_INTERVAL_SECONDS must be >=30') + async function runHost(out, command, maxActions = process.env.MAX_ACTIONS || 10000) { + const logfile = fs.openSync(path.join(out, 'host.log'), 'a', 0o600) + const exit = await new Promise((resolve, reject) => { + child = spawn(process.env.PROVER_BINARY || '/usr/local/bin/m3tering-prover', [command], { + env: { ...process.env, OUTPUT_DIR: out, MAX_ACTIONS: String(maxActions) }, stdio: ['ignore', logfile, logfile] + }) + child.on('error', reject); child.on('exit', (code, signal) => { + if (signal === 'SIGKILL' && !fs.existsSync(path.join(out, 'paid-attempt.json'))) { + fs.writeFileSync(path.join(out, 'reduce-batch.json'), JSON.stringify({reason: 'local execution killed before purchase'})) + } + resolve(code === null ? 1 : code) + }) + }).finally(() => { fs.closeSync(logfile); child = null }) + state.worker = exit === 0 ? 'idle' : 'batch failed; inspect host.log' + console.log(`Batch ${command} finished with exit ${exit}: ${path.basename(out)}`) + return exit + } + async function tick() { + if (stopping) return + try { + const blocked = unresolved(root) + if (blocked.length) { + if (process.env.PROVING_ENABLED !== 'true') { state.worker = 'recovery paused by configuration'; return } + const out = path.join(root, blocked[0]) + if (['request.json','proof.bin','transaction.json'].some(name => fs.existsSync(path.join(out,name)))) { + state.worker = 'recovering paid batch' + await runHost(out,'recover') + } else { state.worker = 'paused: ambiguous paid request'; console.warn('Inspect SP1 request history before retrying:', blocked[0]) } + return + } + state.worker = 'checking queue' + await prepare(db, chain, Number(process.env.MAX_ACTIONS || 10000)) + const ready = await db.one("SELECT count(*)::integer AS count FROM m3ter_payloads WHERE NOT is_verified AND queue_status='ready'") + if (!ready.count) { state.worker = 'idle'; return } + if (process.env.PROVING_ENABLED !== 'true') { state.worker = 'paused by configuration'; return } + let maxActions = Number(process.env.MAX_ACTIONS || 10000) + // Halve only after a local cycle/PGU admission failure, before any purchase. + while (!stopping) { + const out = path.join(root, new Date().toISOString().replace(/[:.]/g, '-') + '-' + require('crypto').randomBytes(4).toString('hex')) + fs.mkdirSync(out) + state.worker = 'proving' + const exit = await runHost(out, 'run', maxActions) + if (exit === 0 || maxActions <= 1 || fs.existsSync(path.join(out,'paid-attempt.json')) || !fs.existsSync(path.join(out,'reduce-batch.json'))) break + maxActions = Math.max(1, Math.floor(maxActions / 2)) + console.log(`Reducing local execution batch to ${maxActions} actions before any proof purchase`) + } + } catch { state.worker = 'retrying after error'; console.warn('Worker check failed; retrying at next interval') } + finally { if (!stopping) timer = setTimeout(() => { activeTask = tick() }, interval * 1000) } + } + // Always wait until startup initialization completes. + timer = setTimeout(() => { activeTask = tick() }, 1000) + return { async stop() { stopping = true; clearTimeout(timer); if (child) child.kill('SIGTERM'); await activeTask } } +} +module.exports = { prepare, unresolved, startWorker } From bc27a65ea423b75681f53acbb764ad3c952eee47 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 27 Oct 2026 10:47:34 +0000 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Preserve=20paid=20batch=20witnesses=20and=20save=20proofs=20?= =?UTF-8?q?atomically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard output directories as soon as a paid attempt exists, serialize access, and flush witnesses before requesting proofs. Document the existing signature format when resetting genesis. Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- README.md | 2 +- host/src/main.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3bdfd41..ae69df1 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Both transports accept the same JSON array used by the previous deployment: POST it to `https://YOUR_HOST/batch-payloads` with `Content-Type: application/json`, or publish it to `STREAM_ID`. The two signed uint32 fields are big-endian nonce and energy in mWh. The API checks exact byte lengths, meter/nonce capacity, Ed25519 signatures against the **current Rollup key**, and already-consumed nonces before storing anything. It derives all accounting fields from the signed bytes. The whole batch is atomic; an invalid action rejects it. Identical retries deduplicate, conflicting actions at the same meter nonce reject, and the database unique constraint also handles concurrent arrivals. HTTP returns 202 with accepted/duplicate counts, 400 for invalid data, 413 for oversized bodies, and 429/503 for retryable congestion or RPC failures. Default limits are 1,000 actions and 256 KiB per HTTP batch. Streamr failures are logged; publishers should retain/retry data or use HTTPS acknowledgements when delivery confirmation is needed. -`GET /health` checks the API/database and reports chain, Rollup and Streamr status; it does not claim that a paid proof has succeeded. A fresh Rollup starts every account and nonce at zero. Meter owners must call `setPublicKey(id,key)`, then meters must start with nonce **1**. Old high-nonce traffic is not a substitute for a genesis checkpoint. The new node uses an independent database. +`GET /health` checks the API/database and reports chain, Rollup and Streamr status; it does not claim that a paid proof has succeeded. A fresh Rollup starts every account and nonce at zero. Meter owners must call `setPublicKey(id,key)`, then meters must start with nonce **1**. Old high-nonce traffic is not a substitute for a genesis checkpoint. The new node uses an independent database. Signatures cover only nonce and energy, without a chain, Rollup address or genesis identifier. Reusing a signing key after resetting nonces therefore allows historical signatures to validate again when their nonces fit. Use a new signing key for a production reset, or explicitly design a versioned signing domain; the requested Sepolia test reset uses the supplied existing meter 7 key. ### Scheduling, budgets and recovery diff --git a/host/src/main.rs b/host/src/main.rs index 01468ea..c2782d7 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -546,7 +546,13 @@ async fn prove_and_send( proof.public_values.as_slice() == t.public_values.as_slice(), "proof statement mismatch" ); - proof.save(out.join("proof.bin"))?; + if !out.join("proof.bin").exists() { + let temporary = out.join("proof.bin.tmp"); + proof.save(&temporary)?; + std::fs::File::open(&temporary)?.sync_all()?; + std::fs::rename(&temporary, out.join("proof.bin"))?; + std::fs::File::open(out)?.sync_all()?; + } preflight(rpc, input, t).await?; let data = commitStateCall { updates: t.payload.clone().into(), @@ -631,6 +637,43 @@ async fn prove_and_send( )?; Ok(()) } +fn has_paid_artifacts(out: &Path) -> bool { + [ + "paid-attempt.json", + "request.json", + "proof.bin", + "transaction.json", + ] + .iter() + .any(|name| out.join(name).exists()) +} +#[cfg(test)] +mod recovery_tests { + #[test] + fn paid_request_blocks_witness_overwrite_before_proof_arrives() { + let dir = std::env::temp_dir().join(format!( + "m3ter-recovery-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&dir).unwrap(); + assert!(!super::has_paid_artifacts(&dir)); + for name in [ + "paid-attempt.json", + "request.json", + "proof.bin", + "transaction.json", + ] { + std::fs::write(dir.join(name), b"{}").unwrap(); + assert!(super::has_paid_artifacts(&dir)); + std::fs::remove_file(dir.join(name)).unwrap(); + } + std::fs::remove_dir(dir).unwrap(); + } +} fn durable_json(path: &Path, value: &Value) -> Result<()> { let file = std::fs::OpenOptions::new() .write(true) @@ -798,6 +841,16 @@ async fn main() -> Result<()> { std::env::var("OUTPUT_DIR").unwrap_or_else(|_| "prover-output".into()), ); std::fs::create_dir_all(&out)?; + let output_lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(out.join(".lock"))?; + output_lock + .try_lock_exclusive() + .context("another process is using OUTPUT_DIR")?; + if mode == "recover" { #[cfg(feature = "proving")] { @@ -824,10 +877,14 @@ async fn main() -> Result<()> { bail!("recovery requires --features proving"); } - ensure!(!out.join("proof.bin").exists() && !out.join("transaction.json").exists(),"OUTPUT_DIR contains a paid proof or transaction: choose a new directory; do not overwrite it"); + ensure!(!has_paid_artifacts(&out), "OUTPUT_DIR contains a paid attempt: use recover or a new directory; do not overwrite its witness"); let (input, t, b) = plan(&rpc, rollup).await?; std::fs::write(out.join("input.json"), serde_json::to_vec_pretty(&input)?)?; std::fs::write(out.join("transition.json"), serde_json::to_vec_pretty(&t)?)?; + std::fs::File::open(out.join("input.json"))?.sync_all()?; + std::fs::File::open(out.join("transition.json"))?.sync_all()?; + std::fs::File::open(&out)?.sync_all()?; + if mode == "run" { #[cfg(feature = "proving")] prove_and_send(&rpc, &input, &t, b, &out).await?; From 14e9c134c235a9f396a3ffd6a9f7e5550a92cf49 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 27 Oct 2026 10:55:14 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Correct=20transaction=20chain=20binding=20and=20test=20signe?= =?UTF-8?q?d=20recovery=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Alloy with_chain_id and test the production request builder by signing and decoding its transaction without the SP1 feature. Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- host/src/main.rs | 58 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/host/src/main.rs b/host/src/main.rs index c2782d7..53f3693 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -582,14 +582,15 @@ async fn prove_and_send( )? .try_into()?; let wallet = EthereumWallet::from(signer); - let tx = TransactionRequest::default() - .to(input.rollup) - .input(Bytes::from(data).into()) - .nonce(nonce) - .chain_id(input.chain_id.try_into()?) - .gas_limit(gas) - .max_fee_per_gas(max_fee) - .max_priority_fee_per_gas(priority); + let tx = commit_transaction( + input.rollup, + data, + input.chain_id.try_into()?, + nonce, + gas, + max_fee, + priority, + ); let signed = tx.build(&wallet).await?; let raw = signed.encoded_2718(); // Save signed bytes BEFORE broadcasting. Recovery rebroadcasts the identical @@ -637,6 +638,24 @@ async fn prove_and_send( )?; Ok(()) } +fn commit_transaction( + rollup: Address, + data: Vec, + chain_id: u64, + nonce: u64, + gas: u64, + max_fee: u128, + priority: u128, +) -> TransactionRequest { + TransactionRequest::default() + .to(rollup) + .input(Bytes::from(data).into()) + .nonce(nonce) + .with_chain_id(chain_id) + .gas_limit(gas) + .max_fee_per_gas(max_fee) + .max_priority_fee_per_gas(priority) +} fn has_paid_artifacts(out: &Path) -> bool { [ "paid-attempt.json", @@ -649,6 +668,29 @@ fn has_paid_artifacts(out: &Path) -> bool { } #[cfg(test)] mod recovery_tests { + #[tokio::test] + async fn signed_transaction_roundtrip_preserves_chain_nonce_destination_and_input() { + use super::*; + use alloy::{ + consensus::{Transaction, TxEnvelope}, + eips::Decodable2718, + }; + let signer = PrivateKeySigner::from_str(&"01".repeat(32)).unwrap(); + let wallet = EthereumWallet::from(signer); + let destination = Address::from([7u8; 20]); + let request = + commit_transaction(destination, vec![1, 2, 3, 4], 11155111, 42, 200000, 100, 2); + let signed = request.build(&wallet).await.unwrap(); + let raw = signed.encoded_2718(); + let decoded = TxEnvelope::decode_2718(&mut raw.as_slice()).unwrap(); + assert_eq!(decoded.chain_id(), Some(11155111)); + assert_eq!(decoded.nonce(), 42); + assert_eq!(decoded.to(), Some(destination)); + assert_eq!(decoded.input().as_ref(), &[1, 2, 3, 4]); + assert_eq!(*decoded.tx_hash(), keccak256(&raw)); + assert_eq!(decoded.tx_hash(), signed.tx_hash()); + } + #[test] fn paid_request_blocks_witness_overwrite_before_proof_arrives() { let dir = std::env::temp_dir().join(format!( From eb8b15952c20838abfe863ebf075e4a9433c01d9 Mon Sep 17 00:00:00 2001 From: "User (Created by Emmo00)" Date: Tue, 27 Oct 2026 11:05:10 +0000 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Check=20rebuilt=20guest=20identity=20and=20document=20releas?= =?UTF-8?q?e=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- .github/workflows/test.yml | 7 +++++-- README.md | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d58f12..b5b456c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.99.1 - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler pkg-config libssl-dev - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check @@ -25,7 +25,10 @@ jobs: env: SP1_ELF: elf/m3tering-program WITNESS_FILE: fixtures/input.json - run: cargo run --locked -p m3tering-prover --features proving -- execute-fixture + run: | + cargo run --locked -p m3tering-prover --features proving -- execute-fixture | tee execution.log + key=$(jq -r .program_key release/program-build.json) + grep -F "Program verification key: $key" execution.log ingestion: runs-on: ubuntu-latest diff --git a/README.md b/README.md index ae69df1..5c52b5d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ SP1_ELF=elf/m3tering-program WITNESS_FILE=fixtures/input.json \ `execute-fixture` executes the actual guest locally, verifies that its public output matches the shared transition code and prints the program verification key. It neither buys a proof nor submits an Ethereum transaction. Re-derive the key after changing the guest, dependencies or toolchain. Set that key and the target-chain verifier/M3ter addresses before deployment. The Sepolia Rollup permits only its deploying wallet to change the vkey and disables that setter on other chains. +When releasing a guest change, copy the rebuilt `elf/m3tering-program` to `release/program.elf`, regenerate `release/program.sha256` with `sha256sum release/program.elf`, and update `release/program-build.json` with the new key, checksum and source revision. Commit these together with the source change. The Protocol workflow rebuilds the guest and checks its verification key against that manifest; the image workflow checks and executes the packaged guest. Review both workflows before deploying a new release. + To regenerate the cross-language fixtures: ```sh From c1d666911a1cb72eedbc7a69bb16a275cbfc18d8 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 27 Oct 2026 11:13:30 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Normalize=20guest=20dependency=20paths=20for=20reproducible?= =?UTF-8?q?=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- .github/workflows/test.yml | 4 +++- README.md | 6 ++++-- ops/build-guest | 9 +++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100755 ops/build-guest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5b456c..ab58953 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,9 @@ jobs: "$HOME/.sp1/bin/sp1up" --version v6.3.1 echo "$HOME/.sp1/bin" >> "$GITHUB_PATH" - name: Build guest - run: cargo prove build --locked -p m3tering-program --output-directory elf + run: | + ./ops/build-guest + cmp release/program.elf elf/m3tering-program - name: Execute authenticated fixture without buying a proof env: SP1_ELF: elf/m3tering-program diff --git a/README.md b/README.md index 5c52b5d..9c5f7a2 100644 --- a/README.md +++ b/README.md @@ -106,14 +106,16 @@ Use Rust **1.99.1** and SP1 **6.3.1**. The proving-enabled host needs protobuf/p cargo fmt --all -- --check cargo test --locked -p m3tering-protocol -p m3tering-prover cargo build --locked -p m3tering-prover --features proving -cargo prove build --locked -p m3tering-program --output-directory elf +./ops/build-guest SP1_ELF=elf/m3tering-program WITNESS_FILE=fixtures/input.json \ cargo run --locked -p m3tering-prover --features proving -- execute-fixture ``` `execute-fixture` executes the actual guest locally, verifies that its public output matches the shared transition code and prints the program verification key. It neither buys a proof nor submits an Ethereum transaction. Re-derive the key after changing the guest, dependencies or toolchain. Set that key and the target-chain verifier/M3ter addresses before deployment. The Sepolia Rollup permits only its deploying wallet to change the vkey and disables that setter on other chains. -When releasing a guest change, copy the rebuilt `elf/m3tering-program` to `release/program.elf`, regenerate `release/program.sha256` with `sha256sum release/program.elf`, and update `release/program-build.json` with the new key, checksum and source revision. Commit these together with the source change. The Protocol workflow rebuilds the guest and checks its verification key against that manifest; the image workflow checks and executes the packaged guest. Review both workflows before deploying a new release. +The guest build script normalizes dependency paths embedded in Rust panic locations to the original build's prefix. Those bytes affect the verification key even when execution is otherwise identical. This does not require creating that directory locally. CI requires the rebuilt guest to match the packaged ELF byte for byte. + +When releasing a guest change, copy the rebuilt `elf/m3tering-program` to `release/program.elf`, regenerate `release/program.sha256` with `sha256sum release/program.elf`, and update `release/program-build.json` with the new key, checksum and source revision. Commit these together with the source change. The Protocol workflow also checks the rebuilt guest's verification key against that manifest; the image workflow checks and executes the packaged guest. Review both workflows before deploying a new release. To regenerate the cross-language fixtures: diff --git a/ops/build-guest b/ops/build-guest new file mode 100755 index 0000000..ae052b6 --- /dev/null +++ b/ops/build-guest @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd -- "$(dirname -- "$0")/.." +# Panic locations are part of the guest image and therefore its verification key. +# Preserve the dependency-path prefix used by the deployed Sepolia guest without +# requiring contributors to create that directory on their own machines. +guest_cargo_home="${CARGO_HOME:-$HOME/.cargo}" +cargo prove build --locked -p m3tering-program --output-directory elf \ + "--rustflags=--remap-path-prefix=$guest_cargo_home=/tmp/native-cargo" From 0a36d7c08333e51df8bdfce185577544c7748746 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 27 Oct 2026 11:17:28 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Include=20protobuf=20definitions=20in=20the=20container=20bu?= =?UTF-8?q?ild?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- Dockerfile | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 98f4248..be07cd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG RUST_IMAGE=rust:1.99.1-bookworm@sha256:48b7f435b8fc6378ce43b44f15e3a47fd8919b2825f835c39bd4bd3da50af5b8 ARG NODE_IMAGE=node:24.16.0-bookworm-slim@sha256:2c87ef9bd3c6a3bd4b472b4bec2ce9d16354b0c574f736c476489d09f560a203 FROM ${RUST_IMAGE} AS builder -RUN apt-get update && apt-get install -y --no-install-recommends protobuf-compiler pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev pkg-config libssl-dev && test -f /usr/include/google/protobuf/empty.proto && rm -rf /var/lib/apt/lists/* WORKDIR /src COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ COPY lib lib diff --git a/README.md b/README.md index 9c5f7a2..ca578c2 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The contract writes in one loop before a static verifier call. Verification fail ## Build and test -Use Rust **1.99.1** and SP1 **6.3.1**. The proving-enabled host needs protobuf/protoc, pkg-config and OpenSSL development headers. Install SP1 from the official Succinct distribution, pinning `sp1up --version v6.3.1`. +Use Rust **1.99.1** and SP1 **6.3.1**. The proving-enabled host needs protobuf/protoc including its well-known `.proto` files (`protobuf-compiler` and `libprotobuf-dev` on Debian), pkg-config and OpenSSL development headers. Install SP1 from the official Succinct distribution, pinning `sp1up --version v6.3.1`. ```sh cargo fmt --all -- --check From 6d76587c44c0ca120f2e898f918e154382d8be2b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 07:05:54 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Point=20node=20setup=20example=20to=20the=20reverse-keystore?= =?UTF-8?q?=20Rollup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index fb219aa..dd94ab7 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ COMPOSE_PROJECT_NAME=m3tering POSTGRES_PASSWORD=replace-with-a-random-long-password CHAIN_ID=11155111 RPC_URL=https://your-sepolia-rpc -ROLLUP_ADDRESS=0xAb683B638f634F0a56e83acAcB81D17900606A79 +ROLLUP_ADDRESS=0x9a9ED9b9A9BEC10d1B5E41b1873E78d19501D5Fa HTTP_PORT=19000 PRIVATE_KEY= SP1_PRIVATE_KEY= From b8d5d17a4402c368e51f8c81c4e640af6b536182 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 30 Oct 2026 00:12:56 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Accept=20payload=20prefixes=20and=20filter=20invalid=20actio?= =?UTF-8?q?ns=20before=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use only the first 72 message bytes, acknowledge individual discards and duplicate relays, and retain valid peers in mixed batches. Keep database writes authenticated and canonical, with retryable infrastructure failures and concurrent nonce conflict protection. Cover HTTP behavior and real PostgreSQL races and rollback. Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- README.md | 8 +++- streamr-client/http.js | 25 +++++++++++ streamr-client/index.js | 21 +++------ streamr-client/test/database.test.js | 31 ++++++++++--- streamr-client/test/http.test.js | 60 ++++++++++++++++++++++++++ streamr-client/test/validation.test.js | 20 ++++++--- streamr-client/validation.js | 46 +++++++++++++------- 7 files changed, 166 insertions(+), 45 deletions(-) create mode 100644 streamr-client/http.js create mode 100644 streamr-client/test/http.test.js diff --git a/README.md b/README.md index ca578c2..86e0f0d 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,17 @@ Also back up the `proofs` Docker volume, which contains paid requests, proofs, s ### Receiving actions -Both transports accept the same JSON array used by the previous deployment: +Both transports accept a JSON array or a single action object: ```json [{"m3ter_id": 7, "message": "<144 hex characters: 8 signed bytes followed by 64 signature bytes>"}] ``` -POST it to `https://YOUR_HOST/batch-payloads` with `Content-Type: application/json`, or publish it to `STREAM_ID`. The two signed uint32 fields are big-endian nonce and energy in mWh. The API checks exact byte lengths, meter/nonce capacity, Ed25519 signatures against the **current Rollup key**, and already-consumed nonces before storing anything. It derives all accounting fields from the signed bytes. The whole batch is atomic; an invalid action rejects it. Identical retries deduplicate, conflicting actions at the same meter nonce reject, and the database unique constraint also handles concurrent arrivals. HTTP returns 202 with accepted/duplicate counts, 400 for invalid data, 413 for oversized bodies, and 429/503 for retryable congestion or RPC failures. Default limits are 1,000 actions and 256 KiB per HTTP batch. Streamr failures are logged; publishers should retain/retry data or use HTTPS acknowledgements when delivery confirmation is needed. +POST it to `https://YOUR_HOST/batch-payloads` with `Content-Type: application/json`, or publish it to `STREAM_ID`. Only the **first 72 bytes** of `message` are used: the first 144 hexadecimal characters after an optional `0x` prefix. Everything after that prefix is ignored and never stored. The two signed uint32 fields are big-endian nonce and energy in mWh, followed by a 64-byte Ed25519 signature over those eight raw bytes. Short/malformed prefixes and invalid signatures are discarded; the validator never searches the suffix for a valid message. + +Ingestion is permissive per entry; storage stays strict. Each retained action must have a valid meter ID and nonce, a valid signature against the **current Rollup key**, and a nonce above the on-chain consumed nonce. Accounting fields come only from signed bytes. Invalid or already-consumed entries are discarded without blocking valid entries in the batch. Identical queued retries deduplicate, even with different ignored suffixes. Conflicts at the same meter/nonce are discarded: the first valid stored action wins. A database unique constraint enforces this under concurrent arrivals. Nonce gaps may be queued and wait for missing predecessors before proving. + +HTTP returns **202** with `{ "accepted": 1, "duplicates": 1, "discarded": 2 }` counts, even when every entry is discarded. An empty array returns all-zero counts. Acknowledgement does not mean every submitted entry was stored or proved. Malformed JSON still returns 400; the 256 KiB request-body and 1,000-entry batch limits return 413. Congestion and RPC/database failures return retryable 429/503 responses. Infrastructure failures do not masquerade as successful discards, and a failed database transaction rolls back its writes. Streamr logs all three counts; publishers should retain/retry data or use HTTPS acknowledgements when delivery confirmation is needed. `GET /health` checks the API/database and reports chain, Rollup and Streamr status; it does not claim that a paid proof has succeeded. A fresh Rollup starts every account and nonce at zero. Meter owners must call `setPublicKey(id,key)`, then meters must start with nonce **1**. Old high-nonce traffic is not a substitute for a genesis checkpoint. The new node uses an independent database. Signatures cover only nonce and energy, without a chain, Rollup address or genesis identifier. Reusing a signing key after resetting nonces therefore allows historical signatures to validate again when their nonces fit. Use a new signing key for a production reset, or explicitly design a versioned signing domain; the requested Sepolia test reset uses the supplied existing meter 7 key. diff --git a/streamr-client/http.js b/streamr-client/http.js new file mode 100644 index 0000000..716c355 --- /dev/null +++ b/streamr-client/http.js @@ -0,0 +1,25 @@ +'use strict' +const express = require('express') + +function createApp(accept, health) { + const app = express() + app.disable('x-powered-by') + app.use(express.json({ limit: '256kb', strict: false })) + app.get('/health', async (_req, res) => { + try { res.json(await health()) } + catch { res.status(503).json({ status: 'database unavailable' }) } + }) + app.post('/batch-payloads', async (req, res) => { + try { res.status(202).json(await accept(req.body, 'https')) } + catch (error) { + const status = [413, 429].includes(error.status) ? error.status : 503 + res.status(status).json({ error: status === 503 ? 'validation unavailable; retry later' : error.message }) + } + }) + app.use((error, _req, res, _next) => { + const status = error.status === 413 ? 413 : 400 + res.status(status).json({ error: status === 413 ? 'request too large' : 'invalid JSON' }) + }) + return app +} +module.exports = { createApp } diff --git a/streamr-client/index.js b/streamr-client/index.js index 5995f35..68d6b7b 100644 --- a/streamr-client/index.js +++ b/streamr-client/index.js @@ -1,6 +1,6 @@ 'use strict' require('dotenv').config({ quiet: true }) -const express = require('express') +const { createApp } = require('./http') const pgp = require('pg-promise')() const { Chain, InvalidPayload, ingest } = require('./validation') const MigrationManager = require('./db/migration-manager') @@ -18,9 +18,6 @@ async function main() { if (bound.chain_id !== identity[0] || bound.rollup !== identity[1]) throw new Error('Database belongs to another chain/Rollup; use a new volume') const chain = new Chain(process.env.RPC_URL, process.env.ROLLUP_ADDRESS, process.env.CHAIN_ID) await chain.snapshot() - const app = express() - app.disable('x-powered-by') - app.use(express.json({ limit: '256kb', strict: true })) let active = false, subscription, streamr, server const state = { streamr: process.env.STREAM_ID ? 'connecting' : 'disabled' } async function accept(items, source) { @@ -29,18 +26,10 @@ async function main() { try { return await ingest(db, chain, items, source, Number(process.env.MAX_BATCH_ACTIONS || 1000)) } finally { active = false } } - app.get('/health', async (_req, res) => { - try { await db.one('SELECT 1 AS ok'); res.json({ status: 'ok', chainId: identity[0], rollup: identity[1], ...state }) } - catch { res.status(503).json({ status: 'database unavailable' }) } + const app = createApp(accept, async () => { + await db.one('SELECT 1 AS ok') + return { status: 'ok', chainId: identity[0], rollup: identity[1], ...state } }) - app.post('/batch-payloads', async (req, res) => { - try { res.status(202).json(await accept(req.body, 'https')) } - catch (error) { - const status = error instanceof InvalidPayload ? 400 : error.status || 503 - res.status(status).json({ error: status === 503 ? 'validation unavailable; retry later' : error.message }) - } - }) - app.use((error, _req, res, _next) => res.status(error.status === 413 ? 413 : 400).json({ error: 'invalid JSON or request too large' })) server = app.listen(Number(process.env.PORT || 3000), process.env.BIND_ADDRESS || '0.0.0.0', () => console.log('HTTP ingestion listening')) // The proving worker runs in a separate container without a public listener. if (process.env.STREAM_ID) { @@ -51,7 +40,7 @@ async function main() { while (!subscription) { try { subscription = await streamr.subscribe(process.env.STREAM_ID, async data => { - try { const result = await accept(data, 'streamr'); console.log(`Streamr accepted ${result.accepted} actions`) } + try { const result = await accept(data, 'streamr'); console.log(`Streamr accepted ${result.accepted}, duplicates ${result.duplicates}, discarded ${result.discarded}`) } catch (error) { console.warn('Streamr batch not queued:', error instanceof InvalidPayload ? error.message : 'temporarily unavailable') } }) state.streamr = 'subscribed' diff --git a/streamr-client/test/database.test.js b/streamr-client/test/database.test.js index 414b9a1..2ea8510 100644 --- a/streamr-client/test/database.test.js +++ b/streamr-client/test/database.test.js @@ -2,7 +2,7 @@ const test = require('node:test'), assert = require('node:assert/strict') const { ed25519 } = require('@noble/curves/ed25519') const { ingest } = require('../validation') const MigrationManager = require('../db/migration-manager') -test('PostgreSQL migrations, atomic validation, duplicate races and conflicting nonce rollback', {skip:!process.env.TEST_DATABASE_URL}, async()=>{ +test('PostgreSQL keeps only canonical validated actions despite mixed input and concurrent retries', {skip:!process.env.TEST_DATABASE_URL}, async()=>{ const pgp=require('pg-promise')(), db=pgp(process.env.TEST_DATABASE_URL) try { await db.task(c=>new MigrationManager(c,require('path').join(__dirname,'../db/migrations')).runMigrations()) @@ -12,11 +12,30 @@ test('PostgreSQL migrations, atomic validation, duplicate races and conflicting const chain={snapshot:async()=>({meter:async()=>({key,nonce:0})})} const results=await Promise.all([ingest(db,chain,[action(1)],'https'),ingest(db,chain,[action(1)],'streamr')]) assert.equal(results.reduce((s,r)=>s+r.accepted,0),1) + assert.equal(results.reduce((s,r)=>s+r.duplicates,0),1) assert.equal((await db.one('SELECT count(*)::integer AS n FROM m3ter_payloads')).n,1) - await assert.rejects(ingest(db,chain,[action(2),action(1,2000)],'https')) - assert.equal((await db.one('SELECT count(*)::integer AS n FROM m3ter_payloads')).n,1) - const invalid=action(2);invalid.message=invalid.message.slice(0,16)+'00'.repeat(64) - await assert.rejects(ingest(db,chain,[action(3),invalid],'streamr')) - assert.equal((await db.one('SELECT count(*)::integer AS n FROM m3ter_payloads')).n,1) + assert.deepEqual(await ingest(db,chain,[action(2),action(1,2000)],'https'),{accepted:1,duplicates:0,discarded:1}) + const invalid=action(3);invalid.message=invalid.message.slice(0,16)+'00'.repeat(64) + const tailed={...action(3),message:'0x'+action(3).message+'extra bytes are ignored'} + assert.deepEqual(await ingest(db,chain,[invalid,tailed,action(3),action(3,2000),null,{m3ter_id:7,message:'00'}],'streamr'),{accepted:1,duplicates:1,discarded:4}) + const rows=await db.any('SELECT nonce,energy,message,signature FROM m3ter_payloads ORDER BY nonce') + assert.equal(rows.length,3) + for(const row of rows){ + assert.equal(row.message,action(row.nonce).message.slice(0,16)) + assert.equal(row.signature,action(row.nonce).message.slice(16)) + assert.equal(Number(row.energy),1000) + } + const consumed={snapshot:async()=>({meter:async()=>({key,nonce:3})})} + assert.deepEqual(await ingest(db,consumed,[action(1),action(4)],'https'),{accepted:1,duplicates:0,discarded:1}) + const conflict=await Promise.all([ingest(db,chain,[action(5,1000)],'https'),ingest(db,chain,[action(5,2000)],'https')]) + assert.equal(conflict.reduce((s,r)=>s+r.accepted,0),1) + assert.equal(conflict.reduce((s,r)=>s+r.discarded,0),1) + // A DB outage must roll back the whole transaction and propagate for retry. + const failingDb={tx:callback=>db.tx(tx=>callback({one:tx.one.bind(tx),oneOrNone:async(sql,args)=>{ + if(args[1]===7)throw Error('simulated database failure') + return tx.oneOrNone(sql,args) + }}))} + await assert.rejects(ingest(failingDb,chain,[action(6),action(7)],'https'),/simulated database failure/) + assert.equal((await db.one('SELECT count(*)::integer AS n FROM m3ter_payloads')).n,5) } finally {pgp.end()} }) diff --git a/streamr-client/test/http.test.js b/streamr-client/test/http.test.js new file mode 100644 index 0000000..9ae4c35 --- /dev/null +++ b/streamr-client/test/http.test.js @@ -0,0 +1,60 @@ +const test = require('node:test'), assert = require('node:assert/strict') +const { ed25519 } = require('@noble/curves/ed25519') +const { once } = require('node:events') +const { createApp } = require('../http') +const { ingest } = require('../validation') + +test('HTTP acknowledges and filters individual payloads while retaining transport and outage errors', async () => { + const secret = new Uint8Array(32).fill(46), key = Buffer.from(ed25519.getPublicKey(secret)).toString('hex') + function action(n) { + const bytes=Buffer.alloc(8);bytes.writeUInt32BE(n);bytes.writeUInt32BE(1000,4) + return {m3ter_id:7,message:bytes.toString('hex')+Buffer.from(ed25519.sign(bytes,secret)).toString('hex')} + } + const stored=new Map() + let unavailable=false,busy=false,dbUnavailable=false + const db={tx:async cb=>{ + if(dbUnavailable)throw Error('private database error') + return cb({ + oneOrNone:async(_sql,args)=>{ + const id=args[0]+':'+args[1] + if(stored.has(id))return null + stored.set(id,{message:args[3],signature:args[4]});return {id} + }, + one:async(_sql,args)=>stored.get(args[0]+':'+args[1]) + }) + }} + const chain={snapshot:async()=>{ + if(unavailable)throw Error('private RPC error') + return {meter:async()=>({key,nonce:1})} + }} + const app=createApp((body,source)=>{ + if(busy){const e=Error('ingestion busy; retry');e.status=429;throw e} + return ingest(db,chain,body,source) + },async()=>({status:'ok'})) + const server=app.listen(0,'127.0.0.1');await once(server,'listening') + async function post(body,raw=false) { + const response=await fetch(`http://127.0.0.1:${server.address().port}/batch-payloads`,{ + method:'POST',headers:{'content-type':'application/json'},body:raw?body:JSON.stringify(body) + }) + return {status:response.status,body:await response.json()} + } + try { + const valid=action(2),tailed={...valid,message:valid.message+'ff'.repeat(500)} + const invalid={...action(3),message:action(3).message.slice(0,16)+'00'.repeat(64)+action(3).message} + assert.deepEqual(await post([tailed,invalid,action(1)]),{status:202,body:{accepted:1,duplicates:0,discarded:2}}) + assert.deepEqual(stored.get('7:2'),{message:valid.message.slice(0,16),signature:valid.message.slice(16)}) + assert.deepEqual(await post(valid),{status:202,body:{accepted:0,duplicates:1,discarded:0}}) + assert.deepEqual(await post([]),{status:202,body:{accepted:0,duplicates:0,discarded:0}}) + assert.deepEqual(await post(null),{status:202,body:{accepted:0,duplicates:0,discarded:1}}) + assert.equal((await post('{',true)).status,400) + assert.equal((await post({message:'x'.repeat(256*1024)})).status,413) + assert.equal((await post(Array(1001).fill(null))).status,413) + unavailable=true + assert.deepEqual(await post(action(3)),{status:503,body:{error:'validation unavailable; retry later'}}) + unavailable=false;dbUnavailable=true + assert.equal((await post(action(3))).status,503) + dbUnavailable=false;busy=true + assert.equal((await post(action(3))).status,429) + assert.equal(stored.size,1) + } finally {server.closeAllConnections();await new Promise(resolve=>server.close(resolve))} +}) diff --git a/streamr-client/test/validation.test.js b/streamr-client/test/validation.test.js index 4ab0b18..bab116d 100644 --- a/streamr-client/test/validation.test.js +++ b/streamr-client/test/validation.test.js @@ -9,22 +9,30 @@ function signed(id, nonce, energy = 600) { const message = Buffer.alloc(8); message.writeUInt32BE(nonce); message.writeUInt32BE(energy, 4) return { m3ter_id: id, message: message.toString('hex') + Buffer.from(ed25519.sign(message, secret)).toString('hex') } } -test('wire compatibility, exact lengths, signed fields and capacity', () => { +test('wire prefix, signed fields and capacity', () => { const action = decode({ ...signed(22, 1), nonce: 999, energy: 999 }) assert.equal(action.nonce, 1); assert.equal(action.energy, 600); authenticate(action, key) - for (const value of [signed(-1,1), signed(17179869184,1), signed(1,0), signed(1,16777216), {m3ter_id:1,message:signed(1,1).message+'00'}]) assert.throws(()=>decode(value)) + for (const value of [signed(-1,1), signed(17179869184,1), signed(1,0), signed(1,16777216), {m3ter_id:1,message:signed(1,1).message.slice(0,-2)}]) assert.throws(()=>decode(value)) + for (const suffix of ['00'.repeat(500), 'garbage', 'f']) { + const tailed = decode({m3ter_id:22,message:'0X'+signed(22,1).message+suffix}) + assert.deepEqual(tailed, action); authenticate(tailed,key) + } + assert.throws(()=>decode({m3ter_id:22,message:'zz'+signed(22,1).message})) const tampered = { ...action, message: '00000001000007d0' } assert.throws(()=>authenticate(tampered,key)); assert.throws(()=>authenticate(action,'00'.repeat(32))) assert.throws(()=>authenticate({...action,signature:'00'.repeat(64)},key)) }) -test('all actions are validated before any database writes', async () => { +test('invalid entries are discarded without DB writes; RPC failures remain retryable', async () => { let writes = 0 const db = { tx: async () => { writes++ } } const chain = { snapshot: async () => ({meter: async () => ({key,nonce:0})}) } const bad = signed(23,1); bad.message = bad.message.slice(0,16) + '00'.repeat(64) - await assert.rejects(ingest(db,chain,[signed(22,1),bad],'https')); assert.equal(writes,0) - await assert.rejects(ingest(db,chain,[signed(22,1),signed(22,1,1000)],'https')); assert.equal(writes,0) - await assert.rejects(ingest(db,{snapshot:async()=>({meter:async()=>({key,nonce:1})})},[signed(22,1)],'https')) + assert.deepEqual(await ingest(db,chain,[bad,{message:'bad'},null,signed(1,0)],'https'),{accepted:0,duplicates:0,discarded:4}) + assert.deepEqual(await ingest(db,{snapshot:async()=>({meter:async()=>({key,nonce:1})})},[signed(22,1)],'https'),{accepted:0,duplicates:0,discarded:1}) + assert.deepEqual(await ingest(db,chain,[],'https'),{accepted:0,duplicates:0,discarded:0}) + const unavailable={snapshot:async()=>({meter:async id=>{if(id===23)throw Error('RPC unavailable');return {key,nonce:0}}})} + await assert.rejects(ingest(db,unavailable,[signed(22,1),signed(23,1)],'https'),/RPC unavailable/) + assert.equal(writes,0) }) test('nonce gaps do not block other meters; consecutive actions aggregate before flooring', async () => { const rows = [signed(1,2),signed(2,1),signed(2,2)].map((x,i)=>({...decode(x),id:i+1})) diff --git a/streamr-client/validation.js b/streamr-client/validation.js index 8274292..fcc6478 100644 --- a/streamr-client/validation.js +++ b/streamr-client/validation.js @@ -17,7 +17,11 @@ function decode(item) { if (!item || !Number.isSafeInteger(item.m3ter_id) || item.m3ter_id < 0 || item.m3ter_id > 17179869183) { throw new InvalidPayload('m3ter_id must be an integer in 0..17179869183') } - const packed = hex(item.message, 72) + // Only the signed 8-byte message and its 64-byte signature enter validation/storage. + // Ignore the suffix entirely, including data that is not hexadecimal. + const raw = item.message + const start = typeof raw === 'string' && raw.slice(0, 2).toLowerCase() === '0x' ? 2 : 0 + const packed = hex(typeof raw === 'string' ? raw.slice(start, start + 144) : raw, 72) const bytes = Buffer.from(packed, 'hex') const nonce = bytes.readUInt32BE(0) if (nonce < 1 || nonce > 16777215) throw new InvalidPayload('nonce must be in 1..16777215') @@ -65,32 +69,44 @@ class Chain { } } async function ingest(db, chain, items, source, max = 1000) { - if (!Array.isArray(items) || !items.length || items.length > max) throw new InvalidPayload(`batch must contain 1..${max} actions`) - // Validate the entire batch before inserting anything. Ignore unsigned metadata. - const actions = items.map(decode), snapshot = await chain.snapshot() - const seen = new Map() + const entries = Array.isArray(items) ? items : [items] + if (entries.length > max) { + const error = new Error(`batch exceeds ${max} actions`); error.status = 413; throw error + } + const counts = { accepted: 0, duplicates: 0, discarded: 0 }, actions = [], valid = [] + for (const item of entries) { + try { actions.push(decode(item)) } + catch (error) { if (!(error instanceof InvalidPayload)) throw error; counts.discarded++ } + } + if (!actions.length) return counts + // RPC failures must remain retryable errors, not successful discards. Complete + // chain validation before starting the DB transaction to avoid partial writes. + const snapshot = await chain.snapshot() for (const action of actions) { const state = await snapshot.meter(action.m3ter_id) - authenticate(action, state.key) - if (action.nonce <= state.nonce) throw new InvalidPayload(`nonce already consumed for meter ${action.m3ter_id}`) - const k = `${action.m3ter_id}:${action.nonce}` - if (seen.has(k) && seen.get(k).message !== action.message) throw new InvalidPayload('conflicting nonce in batch') - seen.set(k, action) + if (action.nonce <= state.nonce) { counts.discarded++; continue } + try { authenticate(action, state.key) } + catch (error) { if (!(error instanceof InvalidPayload)) throw error; counts.discarded++; continue } + valid.push(action) } + if (!valid.length) return counts + // Acquire unique-index locks in a consistent order across concurrent requests. + // Stable sorting preserves first-valid-wins for conflicting entries in a batch. + valid.sort((a,b) => a.m3ter_id - b.m3ter_id || a.nonce - b.nonce) return db.tx(async tx => { - let inserted = 0 - for (const action of seen.values()) { + for (const action of valid) { const row = await tx.oneOrNone(`INSERT INTO m3ter_payloads (m3ter_id,nonce,energy,message,signature,is_verified,source,queue_status) VALUES ($1,$2,$3,$4,$5,false,$6,'pending') ON CONFLICT (m3ter_id,nonce) DO NOTHING RETURNING id`, [action.m3ter_id, action.nonce, action.energy, action.message, action.signature, source]) - if (row) inserted++ + if (row) counts.accepted++ else { const prior = await tx.one('SELECT message,signature FROM m3ter_payloads WHERE m3ter_id=$1 AND nonce=$2', [action.m3ter_id, action.nonce]) - if (prior.message !== action.message || prior.signature !== action.signature) throw new InvalidPayload('conflicting action already queued for this nonce') + if (prior.message === action.message && prior.signature === action.signature) counts.duplicates++ + else counts.discarded++ } } - return { accepted: inserted, duplicates: actions.length - inserted } + return counts }) } module.exports = { decode, authenticate, Chain, InvalidPayload, ingest } From 530c6598e44fc43cb5d970f119a537219d647401 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 31 Oct 2026 15:41:20 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=90=BB=E2=80=8D=E2=9D=84=EF=B8=8F?= =?UTF-8?q?=20Schedule=20proofs=20every=20twelve=20hours=20and=20allow=20S?= =?UTF-8?q?P1=20shared=20memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the worker shared-memory limit so real batches do not inherit Docker’s 64 MiB default. Keep allocation bounded by the worker memory limit and set setup/Compose defaults to twelve hours. Co-authored-by: I. Christwin <52095470+iChristwin@users.noreply.github.com> --- .env.example | 5 ++++- README.md | 4 ++-- compose.yaml | 3 ++- ops/setup.js | 2 +- streamr-client/worker.js | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index dd94ab7..78fa4fd 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ STREAM_ID= # Optional separate identity when the Streamr subscription needs authentication. STREAMR_PRIVATE_KEY= PROVING_ENABLED=false -PROVE_INTERVAL_SECONDS=3600 +PROVE_INTERVAL_SECONDS=43200 MAX_ACTIONS=10000 MAX_SLOTS=4096 MAX_BATCH_ACTIONS=1000 @@ -37,3 +37,6 @@ CONFIRMATIONS=2 # ACTIONS_FILE=actions.json # OUTPUT_DIR=prover-output-batch-0 # SP1_ELF=release/program.elf + +# Shared memory ceiling for SP1 native execution; allocated on demand. +WORKER_SHM_SIZE=1g diff --git a/README.md b/README.md index 86e0f0d..dcc00e7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Setup asks for the RPC URL, deployed Rollup address, Ethereum/SP1 credentials, o The publishing workflow `.github/workflows/images.yml` runs protocol/ingestion tests, builds both Docker targets on GitHub-hosted runners, executes the real SP1 guest in the worker image, and publishes only after its checks pass. Images are `ghcr.io/m3tering/prover:propter-polar-bear` (worker) and `ghcr.io/m3tering/prover:propter-polar-bear-api` (API), with immutable commit-specific `sha-COMMIT` tags as well. Publishing uses GitHub's built-in token; it needs no Ethereum key or proving credits. It never buys a proof. Existing production `main` image tags are not overwritten by this workflow. -To build from source instead, run `./node build`, then `./node start`. Source builds are large: prefer at least 8 GB RAM and 15 GB free disk space. The checked-in Dockerfile pins base-image digests; Rust 1.99.1, Cargo.lock, package-lock.json and SP1 6.3.1 pin application inputs. The guest ELF is checked in with its SHA-256 and program vkey manifest and verified during the image build. OS security packages are resolved at image build time; published image digests identify the exact runtime. Images target Linux amd64; Docker can emulate this on ARM machines, with a performance penalty. `WORKER_MEMORY` sets the worker memory limit (default 4 GB). +To build from source instead, run `./node build`, then `./node start`. Source builds are large: prefer at least 8 GB RAM and 15 GB free disk space. The checked-in Dockerfile pins base-image digests; Rust 1.99.1, Cargo.lock, package-lock.json and SP1 6.3.1 pin application inputs. The guest ELF is checked in with its SHA-256 and program vkey manifest and verified during the image build. OS security packages are resolved at image build time; published image digests identify the exact runtime. Images target Linux amd64; Docker can emulate this on ARM machines, with a performance penalty. `WORKER_MEMORY` sets the worker memory limit (default 4 GB). `WORKER_SHM_SIZE` sets its shared-memory ceiling (default 1 GB); Docker's default 64 MB can crash SP1 native execution on batches larger than the small fixture. This ceiling is allocated on demand and still counts toward the worker memory limit. Compose runs three persistent services: PostgreSQL, an HTTP/Streamr ingestion API, and the scheduled proving worker. The API does **not** receive the Ethereum/SP1 spending keys. PostgreSQL is internal; HTTP binds only to `127.0.0.1:19000`. Database and proof artifacts survive restarts and `docker compose down`; **do not use `down -v`** unless intentionally deleting all state. Each database is bound to one chain and Rollup address. @@ -50,7 +50,7 @@ HTTP returns **202** with `{ "accepted": 1, "duplicates": 1, "discarded": 2 }` c ### Scheduling, budgets and recovery -Set `PROVING_ENABLED=true` to permit paid work. `PROVE_INTERVAL_SECONDS` controls the delay between runs (default 3,600 seconds). The worker checks the queue on startup, serializes its batches, automatically halves the action cap after a local cycle/PGU admission failure (before any purchase), and checks signatures/nonces again before selecting eligible actions. Nonce gaps wait without blocking unrelated meters. Consumed queue entries are retained for audit. A confirmed/finalized chain read is used for queue cleanup; the proof host performs its own current-state checks. +Set `PROVING_ENABLED=true` to permit paid work. `PROVE_INTERVAL_SECONDS` controls the delay after a run finishes before the next run starts (default 43,200 seconds / 12 hours). The worker checks the queue on startup, serializes its batches, automatically halves the action cap after a local cycle/PGU admission failure (before any purchase), and checks signatures/nonces again before selecting eligible actions. Nonce gaps wait without blocking unrelated meters. Consumed queue entries are retained for audit. A confirmed/finalized chain read is used for queue cleanup; the proof host performs its own current-state checks. The host chooses a batch within its mixed fresh/reused slot gas budget, then measures storage-writing gas on the live chain using `eth_estimateGas` with a simulation-only verifier override, shrinks the batch if necessary, reserves verifier overhead and 20% headroom, checks wallet funding and guest identity, and executes locally before purchasing a proof. `MAX_PRICE_PER_PGU=auto` uses the SP1 network's quoted maximum price per PGU, with no added percentage buffer. Set a numeric value for a fixed cap, and optionally `MAX_PROOF_COST_WEI` for an absolute per-proof PROVE limit. Ethereum gas limits do not bound the price of PROVE. Network credit and Ethereum balance checks fail before purchase when insufficient. diff --git a/compose.yaml b/compose.yaml index f736035..139e5a2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -60,6 +60,7 @@ services: worker: <<: *node mem_limit: ${WORKER_MEMORY:-4g} + shm_size: ${WORKER_SHM_SIZE:-1g} command: [node, worker-main.js] environment: <<: *environment @@ -68,7 +69,7 @@ services: SP1_ELF: /opt/m3tering/release/program.elf SP1_CACHE_DIR: /data/cache PROVING_ENABLED: ${PROVING_ENABLED:-false} - PROVE_INTERVAL_SECONDS: ${PROVE_INTERVAL_SECONDS:-3600} + PROVE_INTERVAL_SECONDS: ${PROVE_INTERVAL_SECONDS:-43200} MAX_ACTIONS: ${MAX_ACTIONS:-10000} MAX_SLOTS: ${MAX_SLOTS:-4096} GAS_SCHEDULE: ${GAS_SCHEDULE:-current} diff --git a/ops/setup.js b/ops/setup.js index 2bd19c4..efb654b 100644 --- a/ops/setup.js +++ b/ops/setup.js @@ -14,7 +14,7 @@ async function main(){ env.SP1_PRIVATE_KEY=await ask('SP1 key (hidden; Enter to use the Ethereum key)',true)||env.PRIVATE_KEY if(!/^(0x)?[0-9a-fA-F]{64}$/.test(env.SP1_PRIVATE_KEY))throw new Error('Invalid SP1 key') env.STREAM_ID=await ask('Streamr stream ID (Enter to disable Streamr)') - env.PROVE_INTERVAL_SECONDS=await ask('Proof interval in seconds [3600]')||'3600' + env.PROVE_INTERVAL_SECONDS=await ask('Proof interval in seconds [43200]')||'43200' if(!/^\d+$/.test(env.PROVE_INTERVAL_SECONDS)||Number(env.PROVE_INTERVAL_SECONDS)<30)throw new Error('Interval must be at least 30 seconds') env.MAX_PRICE_PER_PGU='auto';env.PROVING_ENABLED=(await ask('Enable paid scheduled proving? [yes/no]')).toLowerCase()==='yes'?'true':'false' env.MAX_PROOF_COST_WEI=await ask('Optional maximum PROVE wei per proof (Enter for live network quote)') diff --git a/streamr-client/worker.js b/streamr-client/worker.js index 0137597..0175a59 100644 --- a/streamr-client/worker.js +++ b/streamr-client/worker.js @@ -50,7 +50,7 @@ function startWorker(db, chain, state) { return true } }) let stopping = false, child, timer, activeTask = Promise.resolve() - const interval = Number(process.env.PROVE_INTERVAL_SECONDS || 3600) + const interval = Number(process.env.PROVE_INTERVAL_SECONDS || 43200) if (!Number.isSafeInteger(interval) || interval < 30) throw new Error('PROVE_INTERVAL_SECONDS must be >=30') async function runHost(out, command, maxActions = process.env.MAX_ACTIONS || 10000) { const logfile = fs.openSync(path.join(out, 'host.log'), 'a', 0o600)