From d9d92da006ec370ffc8b34555c02be60f018406d Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Tue, 5 May 2026 22:20:54 +0700 Subject: [PATCH 1/4] improve(server): replace Node sidecar with native Rust SDKs (ENG-1700) --- .github/workflows/test.yml | 25 +- apps/app/src/config.ts | 1 - services/server/.env.example | 7 +- services/server/Cargo.lock | 1585 ++++++++++++++- services/server/Cargo.toml | 42 +- services/server/Dockerfile | 24 +- .../server/scripts/bench-recall-latency.ts | 549 ------ services/server/scripts/package-lock.json | 1726 ----------------- services/server/scripts/package.json | 20 - services/server/scripts/seal-decrypt.ts | 179 -- services/server/scripts/seal-encrypt.ts | 116 -- services/server/scripts/sidecar-server.ts | 1039 ---------- services/server/scripts/walrus-upload.ts | 165 -- services/server/src/enoki.rs | 265 +++ services/server/src/main.rs | 65 +- services/server/src/routes.rs | 249 +-- services/server/src/seal.rs | 1300 +++++++++++-- services/server/src/seal_keyserver.rs | 431 ++++ services/server/src/types.rs | 99 +- services/server/src/walrus.rs | 611 ++++-- services/server/src/walrus_onchain.rs | 774 ++++++++ services/server/src/walrus_publisher.rs | 215 ++ 22 files changed, 5114 insertions(+), 4373 deletions(-) delete mode 100644 services/server/scripts/bench-recall-latency.ts delete mode 100644 services/server/scripts/package-lock.json delete mode 100644 services/server/scripts/package.json delete mode 100644 services/server/scripts/seal-decrypt.ts delete mode 100644 services/server/scripts/seal-encrypt.ts delete mode 100644 services/server/scripts/sidecar-server.ts delete mode 100644 services/server/scripts/walrus-upload.ts create mode 100644 services/server/src/enoki.rs create mode 100644 services/server/src/seal_keyserver.rs create mode 100644 services/server/src/walrus_onchain.rs create mode 100644 services/server/src/walrus_publisher.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1a69ba5..a5f96d15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,12 +132,9 @@ jobs: SUI_NETWORK: testnet MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" - # Dummy token — sidecar (scripts/sidecar-server.ts) refuses to start - # without it, and the server panics when the sidecar doesn't come up. - # Not a real secret; SEAL encrypt / Walrus upload paths aren't exercised - # by the CI-scoped tests, so the sidecar running in degraded mode is - # fine for /health + auth-contract checks. - SIDECAR_AUTH_TOKEN: ci-test-sidecar-token-not-for-production + # ENG-1700: Node.js sidecar removed; SEAL/Walrus/Enoki run in-process + # via Mysten Rust SDKs (sui-sdk-types, seal-sdk, walrus_publisher, + # walrus_onchain, enoki). No SIDECAR_AUTH_TOKEN required. steps: - name: Checkout @@ -156,22 +153,6 @@ jobs: key: cargo-${{ runner.os }}-${{ hashFiles('services/server/Cargo.lock') }} restore-keys: cargo-${{ runner.os }}- - - name: Setup Node (for TS sidecar auto-spawned by server) - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Install sidecar deps - working-directory: services/server/scripts - # Server boot spawns `npx tsx sidecar-server.ts` which imports express - # et al — those must be installed up-front or the spawn fails fast. - run: | - if [ -f package-lock.json ]; then - npm ci - else - npm install - fi - - name: Setup Python uses: actions/setup-python@v5 with: diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts index b14f7734..33ec51e1 100644 --- a/apps/app/src/config.ts +++ b/apps/app/src/config.ts @@ -12,7 +12,6 @@ export const config = { suiNetwork: (import.meta.env.VITE_SUI_NETWORK as string || 'testnet') as 'testnet' | 'mainnet', sealKeyServers: (import.meta.env.VITE_SEAL_KEY_SERVERS as string || '') .split(',').map(s => s.trim()).filter(Boolean) as string[], - sidecarUrl: import.meta.env.VITE_SIDECAR_URL as string || 'http://localhost:9000', docsUrl: import.meta.env.VITE_DOCS_URL as string || '', demoUrls: (import.meta.env.VITE_DEMO_URLS as string || '') .split(',').map(s => s.trim()).filter(Boolean) diff --git a/services/server/.env.example b/services/server/.env.example index d6f52837..91659bfb 100644 --- a/services/server/.env.example +++ b/services/server/.env.example @@ -29,7 +29,7 @@ MEMWAL_PACKAGE_ID=0x... MEMWAL_REGISTRY_ID=0x... # SEAL key servers (comma-separated object IDs) -# Required for SEAL encrypt/decrypt in sidecar scripts +# Required for SEAL encrypt/decrypt SEAL_KEY_SERVERS=0x...,0x... # SEAL threshold — minimum number of key servers that must respond for @@ -44,9 +44,12 @@ SEAL_DECRYPT_THRESHOLD=2 # Walrus upload relay (auto-detected from SUI_NETWORK if not set) # WALRUS_UPLOAD_RELAY_URL=https://upload-relay.mainnet.walrus.space -# Enoki Sponsored Transactions (sidecar) +# Enoki Sponsored Transactions ENOKI_API_KEY= ENOKI_NETWORK=mainnet +# When ENOKI_API_KEY errors, fall back to direct-sign with SERVER_SUI_PRIVATE_KEYS[0]. +# Default: true (matches legacy behavior). Set to "false" to fail fast instead. +# ENOKI_FALLBACK_TO_DIRECT_SIGN=true # Sponsor endpoint rate limiting (per IP + per sender, sliding window) SPONSOR_RATE_LIMIT_PER_MINUTE=10 diff --git a/services/server/Cargo.lock b/services/server/Cargo.lock index 3e02e6b2..3f01ded0 100644 --- a/services/server/Cargo.lock +++ b/services/server/Cargo.lock @@ -2,6 +2,68 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -41,6 +103,134 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-secp256k1" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c02e954eaeb4ddb29613fee20840c2bbc85ca4396d53e33837e11905363c5f2" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-secp256r1" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3975a01b0a6e3eae0f72ec7ca8598a6620fc72fa5981f6f5cca33b7cd788f633" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -49,7 +239,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -67,6 +257,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_ops" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7460f7dd8e100147b82a63afca1a20eb6c231ee36b90ba7272e14951cb58af59" + [[package]] name = "autocfg" version = "1.5.0" @@ -134,9 +330,15 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -149,6 +351,46 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b6598a2f5d564fb7855dc6b06fd1c38cff5a72bd8b863a4d021938497b440a" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -158,6 +400,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -167,12 +427,81 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "bnum" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119771309b95163ec7aaf79810da82f7cd0599c19722d48b9c03894dca833966" + +[[package]] +name = "bs58" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bulletproofs" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "012e2e5f88332083bd4235d445ae78081c00b2558443821a9ca5adfe1070073d" +dependencies = [ + "byteorder", + "clear_on_drop", + "curve25519-dalek", + "digest 0.10.7", + "group", + "merlin", + "rand", + "rand_core", + "serde", + "serde_derive", + "sha3", + "subtle", + "thiserror 1.0.69", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -185,6 +514,24 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -192,6 +539,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -210,10 +559,30 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clear_on_drop" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38508a63f4979f0048febc9966fadbd48e5dab31fd0ec6a3f151bbf4a74f7423" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -243,6 +612,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "core-foundation" version = "0.9.4" @@ -308,6 +683,35 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto" +version = "0.6.6" +source = "git+https://github.com/MystenLabs/seal.git?rev=617941a39f#617941a39f6be6724788c162bf99249b7807a606" +dependencies = [ + "bcs", + "fastcrypto", + "fastcrypto-tbls", + "hex", + "itertools 0.14.0", + "rand", + "serde", + "serde_with", + "sui-sdk-types", + "typenum", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -315,9 +719,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -327,9 +741,12 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", + "group", + "rand_core", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -342,7 +759,54 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c359b7249347e46fb28804470d071c921156ad62b3eef5d34e2ba867533dec8" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core", + "subtle-ng", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", ] [[package]] @@ -356,13 +820,56 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", @@ -376,7 +883,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -385,6 +892,26 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -396,6 +923,21 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-consensus" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8465edc8ee7436ffea81d21a019b16676ee3db267aa8d5a8d729581ecf998b" +dependencies = [ + "curve25519-dalek-ng", + "hex", + "rand_core", + "serde", + "sha2 0.9.9", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" @@ -405,7 +947,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -419,6 +961,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -466,12 +1028,112 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastcrypto" +version = "0.1.9" +source = "git+https://github.com/MystenLabs/fastcrypto?rev=5f87e04bb21d295ef6b39375a06615d45cbb906c#5f87e04bb21d295ef6b39375a06615d45cbb906c" +dependencies = [ + "aes", + "aes-gcm", + "aes-gcm-siv", + "ark-ec", + "ark-ff", + "ark-secp256k1", + "ark-secp256r1", + "ark-serialize", + "auto_ops", + "base64ct", + "bcs", + "bech32", + "bincode", + "blake2", + "blst", + "bs58 0.4.0", + "bulletproofs", + "cbc", + "ctr", + "curve25519-dalek", + "derive_more", + "digest 0.10.7", + "ecdsa", + "ed25519-consensus", + "elliptic-curve", + "fastcrypto-derive", + "generic-array", + "hex", + "hex-literal", + "hkdf", + "itertools 0.12.1", + "lazy_static", + "merlin", + "num-bigint", + "once_cell", + "p256", + "rand", + "readonly", + "rfc6979", + "rsa", + "schemars 0.8.22", + "secp256k1", + "serde", + "serde_json", + "serde_with", + "sha2 0.10.9", + "sha3", + "signature", + "static_assertions", + "thiserror 1.0.69", + "tokio", + "tracing", + "typenum", + "zeroize", +] + +[[package]] +name = "fastcrypto-derive" +version = "0.1.3" +source = "git+https://github.com/MystenLabs/fastcrypto?rev=5f87e04bb21d295ef6b39375a06615d45cbb906c#5f87e04bb21d295ef6b39375a06615d45cbb906c" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastcrypto-tbls" +version = "0.1.0" +source = "git+https://github.com/MystenLabs/fastcrypto?rev=5f87e04bb21d295ef6b39375a06615d45cbb906c#5f87e04bb21d295ef6b39375a06615d45cbb906c" +dependencies = [ + "bcs", + "digest 0.10.7", + "fastcrypto", + "hex", + "itertools 0.10.5", + "rand", + "serde", + "serde-big-array", + "sha3", + "tap", + "tracing", + "typenum", + "zeroize", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -598,7 +1260,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -636,8 +1298,10 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ + "serde", "typenum", "version_check", + "zeroize", ] [[package]] @@ -651,6 +1315,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -659,11 +1335,38 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -676,13 +1379,28 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -715,12 +1433,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hkdf" version = "0.12.4" @@ -736,7 +1466,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -832,6 +1562,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -984,6 +1727,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1005,6 +1754,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1017,6 +1777,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1033,6 +1803,24 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1042,12 +1830,31 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1058,6 +1865,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1156,7 +1972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1171,19 +1987,31 @@ version = "0.1.0" dependencies = [ "axum", "base64", + "bcs", + "bech32", "chrono", + "crypto", "dotenvy", "ed25519-dalek", + "fastcrypto", "futures", "hex", + "once_cell", "percent-encoding", "pgvector", + "rand", "redis", "reqwest", + "seal-sdk", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx", + "sui-crypto", + "sui-rpc", + "sui-sdk-types", + "sui-transaction-builder", + "thiserror 1.0.69", "tokio", "tower", "tower-http", @@ -1193,6 +2021,18 @@ dependencies = [ "walrus_rs", ] +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core", + "zeroize", +] + [[package]] name = "mime" version = "0.3.17" @@ -1272,6 +2112,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-integer" version = "0.1.46" @@ -1302,12 +2148,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -1331,7 +2193,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1352,6 +2214,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -1381,6 +2255,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1405,6 +2285,26 @@ dependencies = [ "sqlx", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1450,6 +2350,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1459,6 +2371,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1475,7 +2393,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", ] [[package]] @@ -1487,6 +2414,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + [[package]] name = "quote" version = "1.0.45" @@ -1496,6 +2455,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1532,6 +2497,17 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "readonly" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a62d85ed81ca5305dc544bd42c8804c5060b78ffa5ad3c64b0fb6a8c13d062" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "redis" version = "0.27.6" @@ -1543,7 +2519,7 @@ dependencies = [ "bytes", "combine", "futures-util", - "itertools", + "itertools 0.13.0", "itoa", "num-bigint", "percent-encoding", @@ -1574,6 +2550,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1633,6 +2629,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1647,6 +2653,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roaring" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +dependencies = [ + "bytemuck", + "byteorder", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1654,13 +2670,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", - "digest", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core", + "sha2 0.10.9", "signature", "spki", "subtle", @@ -1695,7 +2712,9 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1706,48 +2725,145 @@ dependencies = [ name = "rustls-pki-types" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ - "zeroize", + "dyn-clone", + "ref-cast", + "serde", + "serde_json", ] [[package]] -name = "rustls-webpki" -version = "0.103.9" +name = "schemars_derive" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "ryu" -version = "1.0.23" +name = "seal-sdk" +version = "0.6.6" +source = "git+https://github.com/MystenLabs/seal.git?rev=617941a39f#617941a39f6be6724788c162bf99249b7807a606" +dependencies = [ + "bcs", + "chrono", + "crypto", + "fastcrypto", + "serde", + "serde_json", + "sui-sdk-types", + "tracing", +] + +[[package]] +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] [[package]] -name = "schannel" -version = "0.1.28" +name = "secp256k1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" dependencies = [ - "windows-sys 0.61.2", + "bitcoin_hashes", + "rand", + "secp256k1-sys", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "secp256k1-sys" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" +dependencies = [ + "cc", +] [[package]] name = "security-framework" @@ -1788,6 +2904,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1805,7 +2930,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1844,6 +2980,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1852,7 +3019,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -1861,6 +3028,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1869,7 +3049,17 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", ] [[package]] @@ -1903,7 +3093,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core", ] @@ -1993,14 +3183,14 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "once_cell", "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tokio", @@ -2020,7 +3210,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2038,12 +3228,12 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -2061,7 +3251,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2082,7 +3272,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2121,7 +3311,7 @@ dependencies = [ "rand", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2163,6 +3353,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -2174,12 +3370,104 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + +[[package]] +name = "sui-crypto" +version = "0.3.0" +source = "git+https://github.com/MystenLabs/sui-rust-sdk.git?rev=e494a36a76a0aab8c5d66d5557995faee5c1fb09#e494a36a76a0aab8c5d66d5557995faee5c1fb09" +dependencies = [ + "ed25519-dalek", + "rand_core", + "signature", + "sui-sdk-types", +] + +[[package]] +name = "sui-rpc" +version = "0.3.0" +source = "git+https://github.com/MystenLabs/sui-rust-sdk.git?rev=e494a36a76a0aab8c5d66d5557995faee5c1fb09#e494a36a76a0aab8c5d66d5557995faee5c1fb09" +dependencies = [ + "base64", + "bcs", + "bytes", + "futures", + "http", + "http-body", + "prost", + "prost-types", + "serde", + "serde_json", + "sui-sdk-types", + "tap", + "tokio", + "tonic", + "tonic-prost", + "tower", +] + +[[package]] +name = "sui-sdk-types" +version = "0.3.0" +source = "git+https://github.com/MystenLabs/sui-rust-sdk.git?rev=e494a36a76a0aab8c5d66d5557995faee5c1fb09#e494a36a76a0aab8c5d66d5557995faee5c1fb09" +dependencies = [ + "base64ct", + "bcs", + "blake2", + "bnum", + "bs58 0.5.1", + "bytes", + "bytestring", + "itertools 0.14.0", + "roaring", + "serde", + "serde_derive", + "serde_json", + "serde_with", + "winnow", +] + +[[package]] +name = "sui-transaction-builder" +version = "0.3.0" +source = "git+https://github.com/MystenLabs/sui-rust-sdk.git?rev=e494a36a76a0aab8c5d66d5557995faee5c1fb09#e494a36a76a0aab8c5d66d5557995faee5c1fb09" +dependencies = [ + "async-trait", + "bcs", + "futures", + "serde", + "sui-rpc", + "sui-sdk-types", + "thiserror 2.0.18", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -2208,7 +3496,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2232,6 +3520,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.26.0" @@ -2271,7 +3565,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2282,7 +3576,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2294,6 +3588,46 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2344,7 +3678,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2391,6 +3725,46 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots", + "zstd", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -2399,9 +3773,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.13.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2458,7 +3835,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2551,6 +3928,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2704,7 +4091,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2734,7 +4121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -2747,7 +4134,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -2761,6 +4148,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -2792,7 +4188,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2803,7 +4199,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3072,6 +4468,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3100,9 +4505,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3118,7 +4523,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3131,7 +4536,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -3150,7 +4555,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -3185,7 +4590,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3206,7 +4611,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3226,7 +4631,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3235,6 +4640,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -3266,7 +4685,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3274,3 +4693,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/services/server/Cargo.toml b/services/server/Cargo.toml index ec939030..a5a13c7f 100644 --- a/services/server/Cargo.toml +++ b/services/server/Cargo.toml @@ -19,8 +19,43 @@ ed25519-dalek = { version = "2", features = ["serde"] } sha2 = "0.10" hex = "0.4" -# SEAL encryption is handled by TS sidecar scripts (@mysten/seal) -# (no Rust crypto deps needed) +# Mysten Sui SDK (replaces TS sidecar's @mysten/sui). +# Pinned to the same revision the seal-sdk crate uses so its +# `ProgrammableTransaction` / `Address` / `UserSignature` types match the +# ones we hand to `signed_request` / `Certificate`. Otherwise rustc treats +# the (otherwise identical) types from crates.io v0.3.1 as a different +# crate and rejects the call. +sui-sdk-types = { git = "https://github.com/MystenLabs/sui-rust-sdk.git", rev = "e494a36a76a0aab8c5d66d5557995faee5c1fb09" } +sui-crypto = { git = "https://github.com/MystenLabs/sui-rust-sdk.git", rev = "e494a36a76a0aab8c5d66d5557995faee5c1fb09", features = ["ed25519"] } +sui-rpc = { git = "https://github.com/MystenLabs/sui-rust-sdk.git", rev = "e494a36a76a0aab8c5d66d5557995faee5c1fb09" } +sui-transaction-builder = { git = "https://github.com/MystenLabs/sui-rust-sdk.git", rev = "e494a36a76a0aab8c5d66d5557995faee5c1fb09" } + +# SEAL threshold encryption (git, official, not yet on crates.io) +# — replaces TS sidecar's @mysten/seal. We add reqwest transport ourselves +# (the SDK ships crypto primitives only, no HTTP client for key servers). +seal-sdk = { git = "https://github.com/MystenLabs/seal.git", rev = "617941a39f" } +# `crypto` is the workspace crate inside the seal repo that ships the IBE +# primitives + EncryptionInput / IBEPublicKeys types we hand to seal_encrypt. +# seal-sdk only re-exports a subset (seal_encrypt, EncryptedObject); we need +# the full set for our native encrypt/decrypt pipeline. +crypto = { git = "https://github.com/MystenLabs/seal.git", rev = "617941a39f", package = "crypto" } +# fastcrypto provides the Ed25519PublicKey / Ed25519Signature types that +# seal-sdk uses inside Certificate / FetchKeyRequest. Pinned to the same +# revision the seal repo uses (read from Cargo.lock for fastcrypto pkg). +fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "5f87e04bb21d295ef6b39375a06615d45cbb906c" } + +# CSPRNG used by genkey (ElGamal ephemeral keypair) + session keypair gen. +rand = "0.8" + +# Process-wide caches (SEAL key-server pubkeys + URLs). +once_cell = "1" + +# BCS canonical serialization (Sui native format) +bcs = "0.1" + +# Bech32 decode (Sui private keys: `suiprivkey1...`). +# Used by walrus_onchain.rs to extract Ed25519 secret bytes from KeyPool entries. +bech32 = "0.9" # Serialization serde = { version = "1", features = ["derive"] } @@ -50,3 +85,6 @@ uuid = { version = "1", features = ["v4"] } chrono = "0.4" base64 = "0.22" percent-encoding = "2" + +# Error types +thiserror = "1" diff --git a/services/server/Dockerfile b/services/server/Dockerfile index 3fba6544..f3721eba 100644 --- a/services/server/Dockerfile +++ b/services/server/Dockerfile @@ -1,6 +1,6 @@ # ============================================================ # memwal Server — Dockerfile -# Multi-stage build: Rust binary + Node.js sidecar scripts +# Single-stage Rust binary (ENG-1700: Node.js sidecar removed) # ============================================================ # ── Stage 1: Build Rust binary ────────────────────────────── @@ -23,11 +23,13 @@ COPY migrations/ migrations/ RUN touch src/main.rs RUN cargo build --release -# ── Stage 2: Runtime (Node.js slim) ───────────────────────── -# LOW-28: pinned by digest for reproducible, supply-chain-safe builds. -# Tag: node:22-bookworm-slim | arch: linux/amd64 | pinned: 2026-04-16 -# To refresh: docker inspect --format='{{index .RepoDigests 0}}' node:22-bookworm-slim -FROM node:22-bookworm-slim@sha256:db9a3a15e8e8e2adbaf1e1c3d93dfb04c2e294bdd027490addb2391b8e61cc6a AS runtime +# ── Stage 2: Runtime (debian-slim, no Node) ───────────────── +# LOW-28: pinned by digest. Was node:22-bookworm-slim (Node sidecar host) — +# replaced with plain debian-slim now that all Mysten SDK calls (SEAL, +# Walrus, Enoki) run in-process via Rust crates (ENG-1700). +# Tag: debian:bookworm-slim | arch: linux/amd64 | pinned: 2026-05-05 +# To refresh: docker inspect --format='{{index .RepoDigests 0}}' debian:bookworm-slim +FROM debian:bookworm-slim AS runtime # Install Rust runtime deps (ca-certificates and libssl3) RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -43,19 +45,11 @@ WORKDIR /app # Copy Rust binary COPY --from=builder --chown=appuser:appuser /app/target/release/memwal-server ./memwal-server -# Copy TS sidecar scripts + install deps -COPY --chown=appuser:appuser scripts/package.json scripts/package-lock.json ./scripts/ -RUN cd scripts && npm ci --omit=dev -COPY --chown=appuser:appuser scripts/*.ts ./scripts/ - # Copy migrations (for reference / manual runs) COPY --chown=appuser:appuser migrations/ ./migrations/ -# Port config — must match defaults in types.rs + sidecar-server.ts +# Port config — must match defaults in types.rs ENV PORT=8000 -ENV SIDECAR_PORT=9000 -ENV SIDECAR_URL=http://localhost:9000 -ENV SIDECAR_SCRIPTS_DIR=/app/scripts EXPOSE ${PORT} CMD ["./memwal-server"] diff --git a/services/server/scripts/bench-recall-latency.ts b/services/server/scripts/bench-recall-latency.ts deleted file mode 100644 index 30e97b62..00000000 --- a/services/server/scripts/bench-recall-latency.ts +++ /dev/null @@ -1,549 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * bench-recall-latency.ts — ENG-1405 - * - * End-to-end latency benchmark for public memory APIs. - * Measures POST /api/remember write latency and POST /api/recall cold/warm read latency. - * - * Usage: - * npx tsx bench-recall-latency.ts \ - * --server-url http://localhost:8000 \ - * --account-id <0x...> \ - * --delegate-key \ - * --remember-text "I prefer concise answers" \ - * --query "what do I prefer?" \ - * --namespace default \ - * --limit 5 \ - * --remember-runs 3 \ - * --cold-runs 3 \ - * --warm-runs 10 \ - * --output benchmark-results/live.json - * - * The script runs `--remember-runs` remember calls first, then `--cold-runs` - * recall calls, then `--warm-runs` recall calls with the same query. - * - * Output: - * • ANSI table with p50/p95/p99 per phase - * • JSON file with raw per-request timings at --output - */ - -import { createHash, randomUUID } from "crypto"; -import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; -import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; - -// ============================================================ -// CLI -// ============================================================ - -const API_METHOD = "POST"; -const REMEMBER_PATH = "/api/remember"; -const RECALL_PATH = "/api/recall"; -const TEXT_ENCODER = new TextEncoder(); - -interface Auth { - delegateKey: string; - keypair: Ed25519Keypair; - publicKeyHex: string; -} - -interface Args { - serverUrl: string; - accountId: string; - auth: Auth; - rememberBodyStr: string; - rememberBodyHash: string; - recallBodyStr: string; - recallBodyHash: string; - rememberText: string; - query: string; - namespace: string; - limit: number; - rememberRuns: number; - coldRuns: number; - warmRuns: number; - output: string; - color: boolean; -} - -function printHelp(): void { - console.log(` -bench-recall-latency.ts — ENG-1405: remember + recall latency benchmark - -Usage: - npx tsx bench-recall-latency.ts [options] - -Required auth options: - --account-id <0x...> x-account-id header value - --delegate-key suiprivkey1... or 64-hex delegate private key - -Optional: - --server-url Server URL [default: http://localhost:8000] - --remember-text Remember text [default: "benchmark memory"] - --query Recall query text [default: "benchmark memory"] - --namespace Namespace [default: default] - --limit Top-K results [default: 5] - --remember-runs Remember runs [default: 3] - --cold-runs Cold recall runs [default: 3] - --warm-runs Warm recall runs [default: 10] - --output JSON output path [default: bench-live-results.json] - --no-color Disable ANSI colors - --help Show this help -`); -} - -function parseArgs(): Args { - const argv = process.argv.slice(2); - if (argv.includes("--help") || argv.includes("-h")) { - printHelp(); - process.exit(0); - } - - const get = (flag: string, def?: string): string | undefined => { - const idx = argv.indexOf(flag); - if (idx !== -1 && idx + 1 < argv.length) return argv[idx + 1]; - return def; - }; - - const required = (flag: string, env?: string): string => { - const v = get(flag) ?? (env ? process.env[env] : undefined); - if (!v) { - console.error(`error: ${flag} is required`); - process.exit(1); - } - return v; - }; - - const delegateKey = normalizeDelegateKey(required("--delegate-key", "BENCH_DELEGATE_KEY")); - const rememberText = get("--remember-text", "benchmark memory")!; - const query = get("--query", rememberText)!; - const namespace = get("--namespace", "default")!; - const limit = parseInt(get("--limit", "5")!, 10); - const rememberBodyStr = JSON.stringify({ text: rememberText, namespace }); - const recallBodyStr = JSON.stringify({ query, namespace, limit }); - - return { - serverUrl: get("--server-url", "http://localhost:8000")!, - accountId: required("--account-id", "BENCH_ACCOUNT_ID"), - auth: buildAuth(delegateKey), - rememberBodyStr, - rememberBodyHash: sha256Hex(rememberBodyStr), - recallBodyStr, - recallBodyHash: sha256Hex(recallBodyStr), - rememberText, - query, - namespace, - limit, - rememberRuns: parseInt(get("--remember-runs", "3")!, 10), - coldRuns: parseInt(get("--cold-runs", "3")!, 10), - warmRuns: parseInt(get("--warm-runs", "10")!, 10), - output: get("--output", "bench-live-results.json")!, - color: !argv.includes("--no-color"), - }; -} - -// ============================================================ -// Helpers -// ============================================================ - -function percentile(sorted: number[], p: number): number { - if (sorted.length === 0) return 0; - const idx = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, Math.min(idx, sorted.length - 1))]; -} - -function ms(n: number): string { - return `${n.toFixed(0)} ms`; -} - -const C = { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - green: "\x1b[32m", - yellow: "\x1b[33m", - red: "\x1b[31m", - cyan: "\x1b[36m", - magenta: "\x1b[35m", -}; - -function color(enabled: boolean, code: string, s: string): string { - return enabled ? `${code}${s}${C.reset}` : s; -} - -function buildAuth(delegateKey: string): Auth { - const keypair = keypairFromDelegateKey(delegateKey); - return { - delegateKey, - keypair, - publicKeyHex: Buffer.from(keypair.getPublicKey().toRawBytes()).toString("hex"), - }; -} - -function keypairFromDelegateKey(delegateKey: string): Ed25519Keypair { - if (delegateKey.startsWith("suiprivkey")) { - const { scheme, secretKey } = decodeSuiPrivateKey(delegateKey); - if (scheme !== "ED25519") { - throw new Error(`delegate key must be Ed25519, got ${scheme}`); - } - return Ed25519Keypair.fromSecretKey(secretKey); - } - - const hex = delegateKey.startsWith("0x") ? delegateKey.slice(2) : delegateKey; - if (!/^[0-9a-fA-F]{64}$/.test(hex)) { - throw new Error("delegate key must be 64-char hex or suiprivkey bech32"); - } - return Ed25519Keypair.fromSecretKey(Uint8Array.from(Buffer.from(hex, "hex"))); -} - -function normalizeDelegateKey(delegateKey: string): string { - if (delegateKey.startsWith("0x") && delegateKey.length === 66) { - return delegateKey.slice(2); - } - return delegateKey; -} - -function sha256Hex(data: string): string { - return createHash("sha256").update(data).digest("hex"); -} - -async function buildSignedHeaders( - args: Args, - path: string, - bodyHash: string, -): Promise> { - const timestamp = Math.floor(Date.now() / 1000).toString(); - const nonce = randomUUID(); - const message = `${timestamp}.${API_METHOD}.${path}.${bodyHash}.${nonce}.${args.accountId}`; - const signature = await args.auth.keypair.sign(TEXT_ENCODER.encode(message)); - - return { - "Content-Type": "application/json", - "x-public-key": args.auth.publicKeyHex, - "x-signature": Buffer.from(signature).toString("hex"), - "x-timestamp": timestamp, - "x-nonce": nonce, - "x-account-id": args.accountId, - "x-delegate-key": args.auth.delegateKey, - }; -} - -function stats(samples: number[]): { - p50: number; p95: number; p99: number; min: number; max: number; mean: number; -} { - if (samples.length === 0) return { p50: 0, p95: 0, p99: 0, min: 0, max: 0, mean: 0 }; - const sorted = [...samples].sort((a, b) => a - b); - const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length; - return { - p50: percentile(sorted, 50), - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - min: sorted[0], - max: sorted[sorted.length - 1], - mean, - }; -} - -// ============================================================ -// Single API calls -// ============================================================ - -interface ApiRunResult { - ok: boolean; - latencyMs: number; - resultCount?: number; - droppedCount?: number; - memoryId?: string; - blobId?: string; - statusCode?: number; - error?: string; -} - -async function rememberOnce(args: Args): Promise { - const start = performance.now(); - try { - const headers = await buildSignedHeaders(args, REMEMBER_PATH, args.rememberBodyHash); - - const resp = await fetch(`${args.serverUrl}${REMEMBER_PATH}`, { - method: API_METHOD, - headers, - body: args.rememberBodyStr, - }); - - const latencyMs = performance.now() - start; - - if (!resp.ok) { - const body = await resp.text().catch(() => ""); - return { ok: false, latencyMs, statusCode: resp.status, error: body.slice(0, 300) }; - } - - const json = (await resp.json()) as { id?: string; blob_id?: string }; - return { - ok: true, - latencyMs, - statusCode: resp.status, - memoryId: json.id, - blobId: json.blob_id, - }; - } catch (err: any) { - const latencyMs = performance.now() - start; - return { ok: false, latencyMs, error: err?.message ?? String(err) }; - } -} - -async function recallOnce(args: Args): Promise { - const start = performance.now(); - try { - const headers = await buildSignedHeaders(args, RECALL_PATH, args.recallBodyHash); - - const resp = await fetch(`${args.serverUrl}${RECALL_PATH}`, { - method: API_METHOD, - headers, - body: args.recallBodyStr, - }); - - const latencyMs = performance.now() - start; - - if (!resp.ok) { - const body = await resp.text().catch(() => ""); - return { ok: false, latencyMs, statusCode: resp.status, error: body.slice(0, 300) }; - } - - const json = (await resp.json()) as { results?: unknown[]; total?: number; dropped_count?: number }; - return { - ok: true, - latencyMs, - statusCode: resp.status, - resultCount: json.total ?? json.results?.length ?? 0, - droppedCount: json.dropped_count ?? 0, - }; - } catch (err: any) { - const latencyMs = performance.now() - start; - return { ok: false, latencyMs, error: err?.message ?? String(err) }; - } -} - -// ============================================================ -// Run a batch of calls -// ============================================================ - -interface BatchResult { - label: string; - runs: number; - successCount: number; - failCount: number; - rawMs: number[]; - errors: string[]; -} - -async function runBatch( - label: string, - runs: number, - callOnce: () => Promise, - formatOk: (result: ApiRunResult) => string, -): Promise { - const rawMs: number[] = []; - const errors: string[] = []; - let successCount = 0; - let failCount = 0; - - for (let i = 0; i < runs; i++) { - process.stdout.write(` [${label}] run ${i + 1}/${runs}... `); - const result = await callOnce(); - if (result.ok) { - rawMs.push(result.latencyMs); - successCount++; - console.log(`ok ${ms(result.latencyMs)} ${formatOk(result)}`); - } else { - failCount++; - const errMsg = result.error ?? `HTTP ${result.statusCode}`; - errors.push(errMsg); - console.log(`FAILED: ${errMsg.slice(0, 120)}`); - } - - if (i < runs - 1) { - await new Promise((r) => setTimeout(r, 100)); - } - } - - return { label, runs, successCount, failCount, rawMs, errors }; -} - -// ============================================================ -// Reporting -// ============================================================ - -function printBatchTable(batches: BatchResult[], col: boolean): void { - const h = (s: string) => color(col, C.bold + C.cyan, s); - const ok = (s: string) => color(col, C.green, s); - - const colW = [12, 8, 8, 8, 10, 8, 8]; - const cols = ["phase", "p50", "p95", "p99", "mean", "min", "max"]; - - function row(cells: string[]): string { - return cells.map((c, i) => c.padStart(colW[i])).join(" "); - } - - console.log(); - console.log(h(row(cols))); - console.log(color(col, C.dim, row(cols.map((_, i) => "─".repeat(colW[i]))))); - - for (const b of batches) { - const s = stats(b.rawMs); - const cells = [ - b.label, - ms(s.p50), - ms(s.p95), - ms(s.p99), - ms(s.mean), - ms(s.min), - ms(s.max), - ]; - console.log(ok(row(cells))); - } - console.log(); -} - -function buildMarkdown(batches: BatchResult[]): string { - const lines = [ - "## MemWal Live Benchmark — ENG-1405", - "", - "| phase | endpoint | runs | p50 | p95 | p99 | mean | min | max | fail% |", - "|-------|----------|------|-----|-----|-----|------|-----|-----|-------|", - ]; - for (const b of batches) { - const s = stats(b.rawMs); - const endpoint = b.label === "remember" ? REMEMBER_PATH : RECALL_PATH; - const failPct = ((b.failCount / b.runs) * 100).toFixed(1); - lines.push( - `| ${b.label} | ${endpoint} | ${b.runs} | ${ms(s.p50)} | ${ms(s.p95)} | ${ms(s.p99)} | ${ms(s.mean)} | ${ms(s.min)} | ${ms(s.max)} | ${failPct}% |` - ); - } - return lines.join("\n"); -} - -// ============================================================ -// Main -// ============================================================ - -async function main(): Promise { - const args = parseArgs(); - const col = args.color && process.stdout.isTTY; - - console.log(color(col, C.bold, "\n⚡ memwal-live-benchmark — ENG-1405\n")); - console.log(` server: ${args.serverUrl}`); - console.log(` namespace: ${args.namespace}`); - console.log(` limit: ${args.limit}`); - console.log(` remember text: "${args.rememberText.slice(0, 60)}"`); - console.log(` query: "${args.query.slice(0, 60)}"`); - console.log(` remember runs: ${args.rememberRuns}`); - console.log(` cold runs: ${args.coldRuns}`); - console.log(` warm runs: ${args.warmRuns}`); - console.log(); - - process.stdout.write(color(col, C.dim, " health check... ")); - const healthResp = await fetch(`${args.serverUrl}/health`).catch((e) => { throw new Error(`health check failed: ${e.message}`); }); - if (!healthResp.ok) throw new Error(`health check failed: HTTP ${healthResp.status}`); - console.log(color(col, C.green, "ok\n")); - - const batches: BatchResult[] = []; - - console.log(color(col, C.magenta, ` ── Remember path (${args.rememberRuns} runs) ──`)); - const rememberBatch = await runBatch( - "remember", - args.rememberRuns, - () => rememberOnce(args), - (result) => `(id=${result.memoryId ?? "unknown"}, blob=${result.blobId ?? "unknown"})`, - ); - batches.push(rememberBatch); - - console.log(color(col, C.magenta, `\n ── Cold recall path (${args.coldRuns} runs) ──`)); - const coldBatch = await runBatch( - "recall-cold", - args.coldRuns, - () => recallOnce(args), - (result) => `(${result.resultCount} results)`, - ); - batches.push(coldBatch); - - console.log(color(col, C.magenta, `\n ── Warm recall path (${args.warmRuns} runs) ──`)); - const warmBatch = await runBatch( - "recall-warm", - args.warmRuns, - () => recallOnce(args), - (result) => `(${result.resultCount} results)`, - ); - batches.push(warmBatch); - - printBatchTable(batches, col); - - const md = buildMarkdown(batches); - console.log(md); - console.log(); - - const TARGET_RECALL_WARM_P50_MS = 500; - const warmStats = stats(warmBatch.rawMs); - const totalFailures = batches.reduce((sum, b) => sum + b.failCount, 0); - if (totalFailures > 0) { - console.log(color(col, C.red, `✘ Benchmark had ${totalFailures} failed request(s)`)); - } - if (warmStats.p50 < TARGET_RECALL_WARM_P50_MS) { - console.log( - color(col, C.green, `✔ Warm recall p50 = ${ms(warmStats.p50)} — below ${TARGET_RECALL_WARM_P50_MS}ms target ✓`) - ); - } else { - console.log( - color(col, C.red, `✘ Warm recall p50 = ${ms(warmStats.p50)} — still above ${TARGET_RECALL_WARM_P50_MS}ms target`) - ); - console.log(" → Check server logs for per-phase breakdown (embed / vector_search / walrus_fetch / seal_batch_decrypt)"); - } - console.log(); - - const jsonOut = { - timestamp: new Date().toISOString(), - config: { - serverUrl: args.serverUrl, - namespace: args.namespace, - limit: args.limit, - rememberText: args.rememberText, - query: args.query, - rememberRuns: args.rememberRuns, - coldRuns: args.coldRuns, - warmRuns: args.warmRuns, - }, - target: { recallWarmP50Ms: TARGET_RECALL_WARM_P50_MS }, - batches: batches.map((b) => { - const s = stats(b.rawMs); - return { - label: b.label, - endpoint: b.label === "remember" ? REMEMBER_PATH : RECALL_PATH, - runs: b.runs, - successCount: b.successCount, - failCount: b.failCount, - failureRate: +(b.failCount / b.runs).toFixed(4), - p50Ms: +s.p50.toFixed(1), - p95Ms: +s.p95.toFixed(1), - p99Ms: +s.p99.toFixed(1), - meanMs: +s.mean.toFixed(1), - minMs: +s.min.toFixed(1), - maxMs: +s.max.toFixed(1), - rawMs: b.rawMs.map((n) => +n.toFixed(1)), - errors: b.errors, - }; - }), - markdownTable: md, - }; - - const { writeFileSync } = await import("fs"); - writeFileSync(args.output, JSON.stringify(jsonOut, null, 2)); - console.log(color(col, C.dim, ` Results written to ${args.output}\n`)); - - if (totalFailures > 0) { - process.exit(1); - } -} - -main().catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - console.error(`\nFatal error: ${msg}`); - process.exit(1); -}); diff --git a/services/server/scripts/package-lock.json b/services/server/scripts/package-lock.json deleted file mode 100644 index 54f9f675..00000000 --- a/services/server/scripts/package-lock.json +++ /dev/null @@ -1,1726 +0,0 @@ -{ - "name": "memwal-server-scripts", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "memwal-server-scripts", - "version": "0.1.0", - "dependencies": { - "@mysten/seal": "1.1.0", - "@mysten/sui": "2.5.0", - "@mysten/walrus": "1.0.3", - "express": "5.1.0", - "tsx": "4.19.0" - }, - "devDependencies": { - "@types/express": "5.0.0", - "typescript": "5.6.3" - } - }, - "node_modules/@0no-co/graphql.web": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", - "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", - "license": "MIT", - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" - }, - "peerDependenciesMeta": { - "graphql": { - "optional": true - } - } - }, - "node_modules/@0no-co/graphqlsp": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.15.2.tgz", - "integrity": "sha512-Ys031WnS3sTQQBtRTkQsYnw372OlW72ais4sp0oh2UMPRNyxxnq85zRfU4PIdoy9kWriysPT5BYAkgIxhbonFA==", - "license": "MIT", - "dependencies": { - "@gql.tada/internal": "^1.0.0", - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" - }, - "peerDependencies": { - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", - "typescript": "^5.0.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@gql.tada/cli-utils": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@gql.tada/cli-utils/-/cli-utils-1.7.2.tgz", - "integrity": "sha512-Qbc7hbLvCz6IliIJpJuKJa9p05b2Jona7ov7+qofCsMRxHRZE1kpAmZMvL8JCI4c0IagpIlWNaMizXEQUe8XjQ==", - "license": "MIT", - "dependencies": { - "@0no-co/graphqlsp": "^1.12.13", - "@gql.tada/internal": "1.0.8", - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" - }, - "peerDependencies": { - "@0no-co/graphqlsp": "^1.12.13", - "@gql.tada/svelte-support": "1.0.1", - "@gql.tada/vue-support": "1.0.1", - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "@gql.tada/svelte-support": { - "optional": true - }, - "@gql.tada/vue-support": { - "optional": true - } - } - }, - "node_modules/@gql.tada/internal": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.0.8.tgz", - "integrity": "sha512-XYdxJhtHC5WtZfdDqtKjcQ4d7R1s0d1rnlSs3OcBEUbYiPoJJfZU7tWsVXuv047Z6msvmr4ompJ7eLSK5Km57g==", - "license": "MIT", - "dependencies": { - "@0no-co/graphql.web": "^1.0.5" - }, - "peerDependencies": { - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", - "typescript": "^5.0.0" - } - }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "license": "MIT", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, - "node_modules/@mysten/bcs": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@mysten/bcs/-/bcs-2.0.2.tgz", - "integrity": "sha512-c/nVRPJEV1fRZdKXhysVsy/yCPdiFt7jn6A4/7W2LH1ZPSVPzRkxtLY362D0zaLuBnyT5Y9d9nFLm3ixI8Goug==", - "license": "Apache-2.0", - "dependencies": { - "@mysten/utils": "^0.3.1", - "@scure/base": "^2.0.0" - } - }, - "node_modules/@mysten/seal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mysten/seal/-/seal-1.1.0.tgz", - "integrity": "sha512-EXNBRCApmfhI2KPnYIUI8AxYzg/RAv49yzZZ1BX0CzAIKeXUfqd1Bj1sK7/VNem2/gPaYJYRFsUfVvEZXIp1lw==", - "license": "Apache-2.0", - "dependencies": { - "@mysten/bcs": "^2.0.2", - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1" - }, - "peerDependencies": { - "@mysten/sui": "^2.5.0" - } - }, - "node_modules/@mysten/sui": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@mysten/sui/-/sui-2.5.0.tgz", - "integrity": "sha512-izKz7ZcwmAymFfkW+T46aWFpRGXJttQifJvh84Yw21QmoxLDtOzpMQW+vFsRbvoUJOmJD8gK5VAN4lP/Q7VtMA==", - "license": "Apache-2.0", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0", - "@mysten/bcs": "^2.0.2", - "@mysten/utils": "^0.3.1", - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1", - "@protobuf-ts/grpcweb-transport": "^2.11.1", - "@protobuf-ts/runtime": "^2.11.1", - "@protobuf-ts/runtime-rpc": "^2.11.1", - "@scure/base": "^2.0.0", - "@scure/bip32": "^2.0.1", - "@scure/bip39": "^2.0.1", - "gql.tada": "^1.9.0", - "graphql": "^16.12.0", - "poseidon-lite": "0.2.1", - "valibot": "^1.2.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@mysten/utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@mysten/utils/-/utils-0.3.1.tgz", - "integrity": "sha512-36KhxG284uhDdSnlkyNaS6fzKTX9FpP2WQWOwUKIRsqQFFIm2ooCf2TP1IuqrtMpkairwpiWkAS0eg7cpemVzg==", - "license": "Apache-2.0", - "dependencies": { - "@scure/base": "^2.0.0" - } - }, - "node_modules/@mysten/walrus": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@mysten/walrus/-/walrus-1.0.3.tgz", - "integrity": "sha512-9pYIOuaCVUdLvdqQh7AUOQ72bnPEMcCYm4+m2ccm6DL30pk8nNpX6PSho0eHu7yYh2WAYC1thMq2r6pkB5IjHQ==", - "license": "Apache-2.0", - "dependencies": { - "@mysten/bcs": "^2.0.2", - "@mysten/utils": "^0.3.1", - "@mysten/walrus-wasm": "^0.2.0", - "dataloader": "^2.2.3" - }, - "peerDependencies": { - "@mysten/sui": "^2.3.2" - } - }, - "node_modules/@mysten/walrus-wasm": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@mysten/walrus-wasm/-/walrus-wasm-0.2.0.tgz", - "integrity": "sha512-QYZoS6CIoFnVxqYClXkwgeoUjnNUyPdXwoD1Re4Xo/TVdXXmpH46akodNJ+e6egUSrjzHgeiRgfAuq57sDXGNA==" - }, - "node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@protobuf-ts/grpcweb-transport": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.11.1.tgz", - "integrity": "sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw==", - "license": "Apache-2.0", - "dependencies": { - "@protobuf-ts/runtime": "^2.11.1", - "@protobuf-ts/runtime-rpc": "^2.11.1" - } - }, - "node_modules/@protobuf-ts/runtime": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", - "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, - "node_modules/@protobuf-ts/runtime-rpc": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz", - "integrity": "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==", - "license": "Apache-2.0", - "dependencies": { - "@protobuf-ts/runtime": "^2.11.1" - } - }, - "node_modules/@scure/base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", - "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", - "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", - "license": "MIT", - "dependencies": { - "@noble/curves": "2.0.1", - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", - "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", - "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.3.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", - "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/dataloader": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", - "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gql.tada": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/gql.tada/-/gql.tada-1.9.0.tgz", - "integrity": "sha512-1LMiA46dRs5oF7Qev6vMU32gmiNvM3+3nHoQZA9K9j2xQzH8xOAWnnJrLSbZOFHTSdFxqn86TL6beo1/7ja/aA==", - "license": "MIT", - "dependencies": { - "@0no-co/graphql.web": "^1.0.5", - "@0no-co/graphqlsp": "^1.12.13", - "@gql.tada/cli-utils": "1.7.2", - "@gql.tada/internal": "1.0.8" - }, - "bin": { - "gql-tada": "bin/cli.js", - "gql.tada": "bin/cli.js" - }, - "peerDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/graphql": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", - "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/poseidon-lite": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/poseidon-lite/-/poseidon-lite-0.2.1.tgz", - "integrity": "sha512-xIr+G6HeYfOhCuswdqcFpSX47SPhm0EpisWJ6h7fHlWwaVIvH3dLnejpatrtw6Xc6HaLrpq05y7VRfvDmDGIog==", - "license": "MIT" - }, - "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==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/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==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tsx": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.0.tgz", - "integrity": "sha512-bV30kM7bsLZKZIOCHeMNVMJ32/LuJzLVajkQI/qf92J2Qr08ueLQvW00PUZGiuLPP760UINwupgUj8qrSCPUKg==", - "license": "MIT", - "dependencies": { - "esbuild": "~0.23.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/valibot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - } - } -} diff --git a/services/server/scripts/package.json b/services/server/scripts/package.json deleted file mode 100644 index c43c381e..00000000 --- a/services/server/scripts/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "memwal-server-scripts", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "sidecar": "tsx sidecar-server.ts" - }, - "dependencies": { - "@mysten/seal": "1.1.0", - "@mysten/sui": "2.5.0", - "@mysten/walrus": "1.0.3", - "express": "5.1.0", - "tsx": "4.19.0" - }, - "devDependencies": { - "@types/express": "5.0.0", - "typescript": "5.6.3" - } -} \ No newline at end of file diff --git a/services/server/scripts/seal-decrypt.ts b/services/server/scripts/seal-decrypt.ts deleted file mode 100644 index 44625c91..00000000 --- a/services/server/scripts/seal-decrypt.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * SEAL Decrypt Sidecar Script - * - * Decrypts SEAL-encrypted data using admin wallet (TEE server). - * Called by the Rust server as a subprocess. - * - * Flow: - * 1. Parse EncryptedObject to extract the key ID - * 2. Create SessionKey signed by admin wallet - * 3. Build seal_approve PTB with the real ID - * 4. Fetch keys from key servers (policy check happens here) - * 5. Decrypt locally using fetched keys - * - * Usage: - * npx tsx seal-decrypt.ts \ - * --data \ - * --private-key \ - * --package-id <0x-package-id> \ - * --registry-id <0x-registry-object-id> - * - * Output (JSON to stdout): - * { "decryptedData": "" } - * - * Errors are written to stderr with non-zero exit code. - */ - -import { SuiJsonRpcClient, getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; -import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; -import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; -import { Transaction } from "@mysten/sui/transactions"; -import { SealClient, SessionKey, EncryptedObject } from "@mysten/seal"; - -// Network config from env vars -const SUI_NETWORK = (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet"; -const SEAL_KEY_SERVERS = (process.env.SEAL_KEY_SERVERS || "") - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - -// ============================================================ -// Parse CLI arguments -// ============================================================ - -function parseArgs(): { - data: Uint8Array; - privateKey: string; - packageId: string; - registryId: string; -} { - const args = process.argv.slice(2); - let data: string | undefined; - let privateKey: string | undefined; - let packageId: string | undefined; - let registryId: string | undefined; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case "--data": - data = args[++i]; - break; - case "--private-key": - privateKey = args[++i]; - break; - case "--package-id": - packageId = args[++i]; - break; - case "--registry-id": - registryId = args[++i]; - break; - case "--help": - console.log( - "usage: seal-decrypt.ts --data --private-key --package-id <0x...> --registry-id <0x...>" - ); - process.exit(0); - } - } - - if (!data || !privateKey || !packageId || !registryId) { - console.error( - "error: required args: --data --private-key --package-id <0x...> --registry-id <0x...>" - ); - process.exit(1); - } - - return { - data: Buffer.from(data, "base64"), - privateKey, - packageId, - registryId, - }; -} - -// ============================================================ -// Main -// ============================================================ - -async function main() { - const { data, privateKey, packageId, registryId } = parseArgs(); - - const suiClient = new SuiJsonRpcClient({ - url: getJsonRpcFullnodeUrl(SUI_NETWORK), - network: SUI_NETWORK, - }); - - // Decode admin wallet (TEE server wallet = deployer) - const { secretKey } = decodeSuiPrivateKey(privateKey); - const keypair = Ed25519Keypair.fromSecretKey(secretKey); - const adminAddress = keypair.getPublicKey().toSuiAddress(); - - // Initialize SEAL client - const sealClient = new SealClient({ - suiClient: suiClient as any, - serverConfigs: SEAL_KEY_SERVERS.map((id) => ({ - objectId: id, - weight: 1, - })), - verifyKeyServers: true, - }); - - // Step 1: Parse the encrypted object to get the real key ID - const encryptedData = new Uint8Array(data); - const parsed = EncryptedObject.parse(encryptedData); - const fullId = parsed.id; // hex string of the owner's address - - // Convert hex ID to byte array for the PTB - const idBytes = Array.from( - Uint8Array.from(fullId.match(/.{1,2}/g)!.map((b: string) => parseInt(b, 16))) - ); - - // Step 2: Create session key (auto-signs with signer) - // LOW-13: Reduced from 30 to 5 minutes to match sidecar policy. - const sessionKey = await SessionKey.create({ - address: adminAddress, - packageId, - ttlMin: 5, - signer: keypair, - suiClient: suiClient as any, - }); - - // Step 3: Build seal_approve PTB with REAL ID - // seal_approve(id: vector, registry: &AccountRegistry, ctx: &TxContext) - const tx = new Transaction(); - tx.moveCall({ - target: `${packageId}::account::seal_approve`, - arguments: [ - tx.pure("vector", idBytes), // real ID from encrypted object - tx.object(registryId), // AccountRegistry shared object - ], - }); - const txBytes = await tx.build({ client: suiClient as any, onlyTransactionKind: true }); - - // Step 4: Fetch keys from key servers (policy check happens here) - await sealClient.fetchKeys({ - ids: [fullId], - txBytes, - sessionKey, - threshold: 1, - }); - - // Step 5: Decrypt locally using fetched keys - const decrypted = await sealClient.decrypt({ - data: encryptedData, - sessionKey, - txBytes, - }); - - // Output as JSON to stdout - const decryptedBase64 = Buffer.from(decrypted).toString("base64"); - console.log(JSON.stringify({ decryptedData: decryptedBase64 })); -} - -main().catch((err) => { - const msg = err instanceof Error ? err.message : String(err); - console.error(`seal-decrypt error: ${msg}`); - if (err instanceof Error && err.stack) { - console.error(err.stack); - } - process.exit(1); -}); diff --git a/services/server/scripts/seal-encrypt.ts b/services/server/scripts/seal-encrypt.ts deleted file mode 100644 index 5dd95423..00000000 --- a/services/server/scripts/seal-encrypt.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * SEAL Encrypt Sidecar Script - * - * Encrypts data using SEAL threshold encryption. - * Called by the Rust server as a subprocess. - * - * Uses @mysten/seal SealClient.encrypt() with the user's address as key ID. - * - * Usage: - * npx tsx seal-encrypt.ts \ - * --data \ - * --owner <0x-sui-address> \ - * --package-id <0x-package-id> - * - * Output (JSON to stdout): - * { "encryptedData": "" } - * - * Errors are written to stderr with non-zero exit code. - */ - -import { SuiJsonRpcClient, getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; -import { SealClient } from "@mysten/seal"; - -// Network config from env vars -const SUI_NETWORK = (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet"; -const SEAL_KEY_SERVERS = (process.env.SEAL_KEY_SERVERS || "") - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - -// ============================================================ -// Parse CLI arguments -// ============================================================ - -function parseArgs(): { - data: Uint8Array; - owner: string; - packageId: string; -} { - const args = process.argv.slice(2); - let data: string | undefined; - let owner: string | undefined; - let packageId: string | undefined; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case "--data": - data = args[++i]; - break; - case "--owner": - owner = args[++i]; - break; - case "--package-id": - packageId = args[++i]; - break; - case "--help": - console.log( - "usage: seal-encrypt.ts --data --owner <0x...> --package-id <0x...>" - ); - process.exit(0); - } - } - - if (!data || !owner || !packageId) { - console.error( - "error: required args: --data --owner <0x...> --package-id <0x...>" - ); - process.exit(1); - } - - return { - data: Buffer.from(data, "base64"), - owner, - packageId, - }; -} - -// ============================================================ -// Main -// ============================================================ - -async function main() { - const { data, owner, packageId } = parseArgs(); - - const suiClient = new SuiJsonRpcClient({ - url: getJsonRpcFullnodeUrl(SUI_NETWORK), - network: SUI_NETWORK, - }); - - const sealClient = new SealClient({ - suiClient: suiClient as any, - serverConfigs: SEAL_KEY_SERVERS.map((id) => ({ - objectId: id, - weight: 1, - })), - verifyKeyServers: true, - }); - - // Encrypt with threshold 1 (need 1 of N key servers to decrypt) - // The SEAL SDK uses packageId + id to derive the encryption key - const result = await sealClient.encrypt({ - threshold: 1, - packageId, - id: owner, - data: new Uint8Array(data), - }); - - // Output as JSON to stdout - const encryptedBase64 = Buffer.from(result.encryptedObject).toString("base64"); - console.log(JSON.stringify({ encryptedData: encryptedBase64 })); -} - -main().catch((err) => { - console.error(`seal-encrypt error: ${err.message || err}`); - process.exit(1); -}); diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts deleted file mode 100644 index 1457a7a1..00000000 --- a/services/server/scripts/sidecar-server.ts +++ /dev/null @@ -1,1039 +0,0 @@ -/** - * SEAL + Walrus HTTP Sidecar Server - * - * Long-lived Express server that wraps SEAL encrypt/decrypt and Walrus upload. - * Started once at server boot — eliminates ~1-2s Node.js cold-start per call. - * - * Endpoints: - * POST /seal/encrypt → { data, owner, packageId } → { encryptedData } - * POST /seal/decrypt → { data, privateKey, packageId, registryId } → { decryptedData } - * POST /walrus/upload → { data, privateKey, owner, epochs } → { blobId, objectId } - * GET /health → { status: "ok" } - */ - -import express, { Request, Response, NextFunction } from "express"; -import { timingSafeEqual, randomUUID } from "crypto"; -import { SuiJsonRpcClient, getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; -import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; -import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; -import { Transaction } from "@mysten/sui/transactions"; -import { SealClient, SessionKey, EncryptedObject } from "@mysten/seal"; -import { WalrusClient } from "@mysten/walrus"; - -// ============================================================ -// Shared clients (initialized once at boot — the whole point!) -// ============================================================ -// ============================================================ -// Environment-driven network config -// ============================================================ - -const SUI_NETWORK = (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet"; - -// SEAL key server object IDs (comma-separated via env var) -const SEAL_KEY_SERVERS = (process.env.SEAL_KEY_SERVERS || "") - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - -if (SEAL_KEY_SERVERS.length === 0) { - console.error("[sidecar] WARNING: SEAL_KEY_SERVERS env var is empty — SEAL encrypt/decrypt will fail"); -} - -const SEAL_THRESHOLD = parseInt(process.env.SEAL_THRESHOLD || "2", 10); - -// Server Sui Private Keys for Walrus uploads -const SERVER_SUI_PRIVATE_KEYS = (process.env.SERVER_SUI_PRIVATE_KEYS || "") - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - -if (SERVER_SUI_PRIVATE_KEYS.length === 0 && process.env.SERVER_SUI_PRIVATE_KEY) { - SERVER_SUI_PRIVATE_KEYS.push(process.env.SERVER_SUI_PRIVATE_KEY.trim()); -} - -if (SERVER_SUI_PRIVATE_KEYS.length === 0) { - console.error("[sidecar] WARNING: SERVER_SUI_PRIVATE_KEYS env var is empty — Walrus uploads will fail"); -} - -// Walrus package ID (for on-chain Move calls: metadata, blob type queries) -const WALRUS_PACKAGE_ID = process.env.WALRUS_PACKAGE_ID || ( - SUI_NETWORK === "testnet" - ? "0xd84704c17fc870b8764832c535aa6b11f21a95cd6f5bb38a9b07d2cf42220c66" - : "0xfdc88f7d7cf30afab2f82e8380d11ee8f70efb90e863d1de8616fae1bb09ea77" -); - -const WALRUS_UPLOAD_RELAY_URL = process.env.WALRUS_UPLOAD_RELAY_URL || ( - SUI_NETWORK === "testnet" - ? "https://upload-relay.testnet.walrus.space" - : "https://upload-relay.mainnet.walrus.space" -); - -const DEFAULT_WALRUS_EPOCHS = SUI_NETWORK === "testnet" ? 50 : 3; - -const suiClient = new SuiJsonRpcClient({ - url: getJsonRpcFullnodeUrl(SUI_NETWORK), - network: SUI_NETWORK, -}); - -const sealClient = new SealClient({ - suiClient: suiClient as any, - serverConfigs: SEAL_KEY_SERVERS.map((id) => ({ - objectId: id, - weight: 1, - })), - verifyKeyServers: true, -}); - -const walrusClient = new WalrusClient({ - network: SUI_NETWORK, - suiClient: suiClient as any, - uploadRelay: { - host: WALRUS_UPLOAD_RELAY_URL, - sendTip: { max: 10_000_000 }, - }, -}); - -const COIN_WITH_BALANCE_INTENT = "CoinWithBalance"; -const GAS_INTENT_TYPE = "gas"; -const SUI_TYPE = "0x2::sui::SUI"; -type TxIntentCommand = { - $kind?: string; - $Intent?: { - name?: string; - data?: { type?: string }; - }; -}; -type TxDataWithCommands = { commands: TxIntentCommand[] }; -type UploadRelayTipConfigResponse = { - send_tip?: { - address?: string; - }; -}; - -/** - * Rewrite CoinWithBalance "gas" intents to explicit SUI coin type so Enoki - * sponsorship can build the transaction (Enoki rejects GasCoin tx arguments). - */ -function patchGasCoinIntents(tx: Transaction): void { - tx.addSerializationPlugin(async (transactionData: TxDataWithCommands, _buildOptions, next) => { - let patched = 0; - for (const command of transactionData.commands) { - if ( - command.$kind === "$Intent" && - command.$Intent?.name === COIN_WITH_BALANCE_INTENT && - command.$Intent?.data?.type === GAS_INTENT_TYPE - ) { - command.$Intent.data.type = SUI_TYPE; - patched += 1; - } - } - - if (patched > 0) { - console.log(`[patch] converted ${patched} CoinWithBalance intent(s) from GasCoin -> sender SUI coins`); - } - - await next(); - }); -} - -const ENOKI_API_BASE_URL = "https://api.enoki.mystenlabs.com/v1"; -const enokiApiKey = process.env.ENOKI_API_KEY; -const enokiNetwork = (process.env.ENOKI_NETWORK || process.env.SUI_NETWORK || "mainnet") as - | "mainnet" - | "testnet" - | "devnet"; -const ENOKI_FALLBACK_TO_DIRECT_SIGN = (() => { - const raw = (process.env.ENOKI_FALLBACK_TO_DIRECT_SIGN || "true").trim().toLowerCase(); - return raw !== "0" && raw !== "false" && raw !== "no"; -})(); - -type EnokiDataWrapper = { data: T }; -type EnokiSponsorResponse = { bytes: string; digest: string }; -type EnokiExecuteResponse = { digest: string }; -const signerUploadQueues = new Map>(); -let uploadRelayTipAddressCache: string | null | undefined = undefined; - -function dedupeAddresses(addresses: (string | null | undefined)[]): string[] { - return [...new Set(addresses.filter((addr): addr is string => typeof addr === "string" && addr.length > 0))]; -} - -async function getUploadRelayTipAddress(): Promise { - if (uploadRelayTipAddressCache !== undefined) { - return uploadRelayTipAddressCache; - } - - try { - const resp = await fetch(`${WALRUS_UPLOAD_RELAY_URL}/v1/tip-config`); - if (!resp.ok) { - throw new Error(`tip-config request failed (${resp.status})`); - } - - const json = await resp.json() as UploadRelayTipConfigResponse; - const address = json.send_tip?.address; - if (typeof address === "string" && address.startsWith("0x")) { - uploadRelayTipAddressCache = address; - return address; - } - - uploadRelayTipAddressCache = null; - return null; - } catch (err: any) { - console.warn(`[upload-relay] could not load tip-config: ${err.message || err}`); - // Don't cache transient failures; retry on next request. - return null; - } -} - -async function callEnoki(path: string, payload: unknown): Promise { - if (!enokiApiKey) { - throw new Error("ENOKI_API_KEY is not configured"); - } - - const resp = await fetch(`${ENOKI_API_BASE_URL}${path}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${enokiApiKey}`, - }, - body: JSON.stringify(payload), - }); - - const text = await resp.text(); - if (!resp.ok) { - throw new Error(`Enoki API error (${resp.status}): ${text}`); - } - - const parsed = JSON.parse(text) as EnokiDataWrapper; - return parsed.data; -} - -async function executeWithEnokiSponsor(tx: Transaction, signer: Ed25519Keypair, allowedAddresses?: string[]): Promise { - if (!enokiApiKey) { - const direct = await suiClient.signAndExecuteTransaction({ - signer, - transaction: tx, - }); - return direct.digest; - } - - try { - const txKindBytes = await tx.build({ - client: suiClient as any, - onlyTransactionKind: true, - }); - - const sponsored = await callEnoki("/transaction-blocks/sponsor", { - network: enokiNetwork, - transactionBlockKindBytes: Buffer.from(txKindBytes).toString("base64"), - sender: signer.toSuiAddress(), - ...(allowedAddresses?.length ? { allowedAddresses } : {}), - }); - - const signature = await signer.signTransaction( - new Uint8Array(Buffer.from(sponsored.bytes, "base64")) - ); - - // LOW-15: Defense-in-depth — encode digest before path interpolation. - const encodedSponsoredDigest = encodeURIComponent(sponsored.digest); - const executed = await callEnoki( - `/transaction-blocks/sponsor/${encodedSponsoredDigest}`, - { - digest: sponsored.digest, - signature: signature.signature, - } - ); - - return executed.digest; - } catch (err: any) { - const errMsg = err?.message || String(err); - if (!ENOKI_FALLBACK_TO_DIRECT_SIGN) { - console.error(`[enoki-sponsor] sponsor failed and fallback disabled: ${errMsg}`); - throw err; - } - - console.warn(`[enoki-sponsor] sponsor failed, falling back to direct signing: ${errMsg}`); - const direct = await suiClient.signAndExecuteTransaction({ - signer, - transaction: tx, - }); - return direct.digest; - } -} - -/** - * Queue tasks by signer to avoid coin-object lock conflicts when multiple - * Walrus uploads are triggered concurrently for the same signing key. - */ -async function runExclusiveBySigner(signerAddress: string, task: () => Promise): Promise { - const previous = signerUploadQueues.get(signerAddress) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((resolve) => { - release = resolve; - }); - const queued = previous.then(() => current); - signerUploadQueues.set(signerAddress, queued); - - await previous; - try { - return await task(); - } finally { - release(); - // Cleanup queue map entry once this task is done and no newer task replaced it. - if (signerUploadQueues.get(signerAddress) === queued) { - signerUploadQueues.delete(signerAddress); - } - } -} - -// ============================================================ -// Express app -// ============================================================ - -const app = express(); -// HIGH-13: Use a conservative global default — routes that need more bytes -// (e.g. /walrus/upload, /seal/decrypt-batch) apply their own per-route -// json() middleware that overrides this default. -// Global floor: 256 KiB is enough for every metadata-only JSON body -// (seal/encrypt, seal/decrypt, walrus/query-blobs, sponsor, sponsor/execute). -app.use(express.json({ limit: "256kb" })); - -// CORS — sidecar is called only by the co-located Rust server, never by browsers. -// Remove all CORS headers so no cross-origin access is granted. -app.use((_req: Request, res: Response, next: NextFunction) => { - res.removeHeader("Access-Control-Allow-Origin"); - res.removeHeader("Access-Control-Allow-Methods"); - res.removeHeader("Access-Control-Allow-Headers"); - if (_req.method === "OPTIONS") { - return res.sendStatus(204); - } - next(); -}); - -// Health check — placed before auth middleware so it is always reachable. -app.get("/health", (_req: Request, res: Response) => { - res.json({ status: "ok" }); -}); - -// Shared-secret authentication — protects all routes registered after this point. -// Set SIDECAR_AUTH_TOKEN in the environment; callers must send it as Authorization: Bearer . -// Sidecar refuses to start if SIDECAR_AUTH_TOKEN is not set. -const SIDECAR_AUTH_TOKEN = process.env.SIDECAR_AUTH_TOKEN; -if (!SIDECAR_AUTH_TOKEN) { - console.error("[sidecar] FATAL: SIDECAR_AUTH_TOKEN not set. Refusing to start without auth."); - process.exit(1); -} - -app.use((req: Request, res: Response, next: NextFunction) => { - const token = req.headers.authorization?.replace("Bearer ", ""); - const secretBuf = Buffer.from(SIDECAR_AUTH_TOKEN!); - const providedBuf = Buffer.from(typeof token === "string" ? token : ""); - // timingSafeEqual prevents timing side-channel attacks on the token comparison. - // Buffers must be same length — if lengths differ it's already a mismatch. - const valid = providedBuf.length === secretBuf.length && - timingSafeEqual(providedBuf, secretBuf); - if (!valid) { - return res.status(401).json({ error: "Unauthorized" }); - } - next(); -}); - -// ============================================================ -// POST /seal/encrypt -// ============================================================ -app.post("/seal/encrypt", async (req, res) => { - try { - const { data, owner, packageId } = req.body; - if (!data || !owner || !packageId) { - return res.status(400).json({ error: "Missing required fields: data, owner, packageId" }); - } - - const plaintext = Buffer.from(data, "base64"); - const result = await sealClient.encrypt({ - threshold: SEAL_THRESHOLD, - packageId, - id: owner, - data: new Uint8Array(plaintext), - }); - - const encryptedBase64 = Buffer.from(result.encryptedObject).toString("base64"); - res.json({ encryptedData: encryptedBase64 }); - } catch (err: any) { - const traceId = randomUUID(); - console.error(`[seal/encrypt] [${traceId}] error:`, err); - res.status(500).json({ error: "Internal server error", traceId }); - } -}); - -/** - * ENG-1697: Resolve a SEAL SessionKey from the request headers. - * - * Preferred path: `x-seal-session` contains a base64-encoded - * `ExportedSessionKey` (built by the SDK on the client). We import it and - * skip touching any private-key material. - * - * Legacy path: `x-delegate-key` contains the raw delegate private key - * (hex or suiprivkey bech32). We reconstruct the keypair and build the - * SessionKey here — same behavior as before the migration. This path - * will be removed at EOL once all SDK clients emit `x-seal-session`. - * - * Returns `null` when neither header is present so the caller can emit a - * 400 with a clear error message. - */ -async function resolveSessionKey( - req: express.Request, - packageId: string, -): Promise { - const sessionHeader = req.headers["x-seal-session"] as string | undefined; - if (sessionHeader) { - const exportedJson = Buffer.from(sessionHeader, "base64").toString("utf8"); - const exported = JSON.parse(exportedJson); - return SessionKey.import(exported, suiClient as any); - } - - const privateKey = req.headers["x-delegate-key"] as string | undefined; - if (!privateKey) return null; - - let keypair: Ed25519Keypair; - if (privateKey.startsWith("suiprivkey")) { - const { secretKey } = decodeSuiPrivateKey(privateKey); - keypair = Ed25519Keypair.fromSecretKey(secretKey); - } else { - // LOW-12: Validate hex format before parsing to prevent injection - if (!/^[0-9a-fA-F]+$/.test(privateKey) || privateKey.length !== 64) { - throw new Error("privateKey must be 64-char hex string or suiprivkey bech32"); - } - const keyBytes = Uint8Array.from( - privateKey.match(/.{1,2}/g)!.map((b: string) => parseInt(b, 16)), - ); - keypair = Ed25519Keypair.fromSecretKey(keyBytes); - } - return await SessionKey.create({ - address: keypair.getPublicKey().toSuiAddress(), - packageId, - ttlMin: 5, - signer: keypair, - suiClient: suiClient as any, - }); -} - -// ============================================================ -// POST /seal/decrypt -// ============================================================ -app.post("/seal/decrypt", async (req, res) => { - try { - const { data, packageId, accountId } = req.body; - if (!data || !packageId || !accountId) { - return res.status(400).json({ error: "Missing required fields: data, packageId, accountId" }); - } - - // ENG-1697: resolve credential (x-seal-session preferred; legacy - // x-delegate-key supported during the deprecation window). - const sessionKey = await resolveSessionKey(req, packageId); - if (!sessionKey) { - return res.status(400).json({ - error: "Missing credential: provide x-seal-session (preferred) or x-delegate-key header", - }); - } - - // Parse encrypted object to get key ID - const encryptedData = new Uint8Array(Buffer.from(data, "base64")); - const parsed = EncryptedObject.parse(encryptedData); - const fullId = parsed.id; - - // Convert hex ID to byte array for PTB - const idBytes = Array.from( - Uint8Array.from(fullId.match(/.{1,2}/g)!.map((b: string) => parseInt(b, 16))) - ); - - // Build seal_approve PTB — pass MemWalAccount (owned object) instead of AccountRegistry - const tx = new Transaction(); - tx.moveCall({ - target: `${packageId}::account::seal_approve`, - arguments: [ - tx.pure("vector", idBytes), - tx.object(accountId), - ], - }); - const txBytes = await tx.build({ client: suiClient as any, onlyTransactionKind: true }); - - // Fetch keys from key servers - await sealClient.fetchKeys({ - ids: [fullId], - txBytes, - sessionKey, - threshold: SEAL_THRESHOLD, - }); - - // Decrypt locally - const decrypted = await sealClient.decrypt({ - data: encryptedData, - sessionKey, - txBytes, - }); - - const decryptedBase64 = Buffer.from(decrypted).toString("base64"); - res.json({ decryptedData: decryptedBase64 }); - } catch (err: any) { - const traceId = randomUUID(); - console.error(`[seal/decrypt] [${traceId}] error:`, err); - res.status(500).json({ error: "Internal server error", traceId }); - } -}); - -// ============================================================ -// POST /seal/decrypt-batch -// Decrypt multiple SEAL-encrypted blobs with a single SessionKey. -// Avoids "Not enough shares" errors when decrypting many blobs at once. -// ============================================================ -// HIGH-13: batch body can be large (up to 25 × ~320 KiB max-item = ~8 MB) -// Apply a per-route json() that overrides the 256 KiB global for this endpoint only. -app.post("/seal/decrypt-batch", express.json({ limit: "8mb" }), async (req, res) => { - try { - const { items, packageId, accountId } = req.body; - if (!items || !Array.isArray(items) || items.length === 0) { - return res.status(400).json({ error: "Missing required field: items (array of base64 encrypted data)" }); - } - // HIGH-13 / MED-13: Cap items. 25 × max-item body = ~8 MB (matches the - // per-route body limit above). Tightened from 50 to 25 so worst-case - // in-memory allocation stays bounded even at the new limit. - if (items.length > 25) { - return res.status(400).json({ error: "items array exceeds maximum of 25 elements" }); - } - if (!packageId || !accountId) { - return res.status(400).json({ error: "Missing required fields: packageId, accountId" }); - } - - // ENG-1697: resolve credential (x-seal-session preferred; legacy - // x-delegate-key supported during the deprecation window). - const sessionKey = await resolveSessionKey(req, packageId); - if (!sessionKey) { - return res.status(400).json({ - error: "Missing credential: provide x-seal-session (preferred) or x-delegate-key header", - }); - } - - // Parse all encrypted objects and collect unique SEAL IDs - const parsedItems: { index: number; encryptedData: Uint8Array; fullId: string }[] = []; - const errors: { index: number; error: string }[] = []; - - for (let i = 0; i < items.length; i++) { - try { - const encryptedData = new Uint8Array(Buffer.from(items[i], "base64")); - const parsed = EncryptedObject.parse(encryptedData); - parsedItems.push({ index: i, encryptedData, fullId: parsed.id }); - } catch (err: any) { - errors.push({ index: i, error: `parse failed: ${err.message}` }); - } - } - - if (parsedItems.length === 0) { - return res.json({ results: [], errors }); - } - - // Collect all unique IDs - const allIds = [...new Set(parsedItems.map(p => p.fullId))]; - - // Build ONE PTB with seal_approve for ALL IDs - const tx = new Transaction(); - for (const id of allIds) { - const idBytes = Array.from( - Uint8Array.from(id.match(/.{1,2}/g)!.map((b: string) => parseInt(b, 16))) - ); - tx.moveCall({ - target: `${packageId}::account::seal_approve`, - arguments: [ - tx.pure("vector", idBytes), - tx.object(accountId), - ], - }); - } - const txBytes = await tx.build({ client: suiClient as any, onlyTransactionKind: true }); - - // ONE fetchKeys call for ALL IDs - await sealClient.fetchKeys({ - ids: allIds, - txBytes, - sessionKey, - threshold: SEAL_THRESHOLD, - }); - - // Decrypt each blob using the shared sessionKey - const results: { index: number; decryptedData: string }[] = []; - - for (const item of parsedItems) { - try { - const decrypted = await sealClient.decrypt({ - data: item.encryptedData, - sessionKey, - txBytes, - }); - results.push({ - index: item.index, - decryptedData: Buffer.from(decrypted).toString("base64"), - }); - } catch (err: any) { - errors.push({ index: item.index, error: `decrypt failed: ${err.message}` }); - } - } - - console.log(`[seal/decrypt-batch] ${results.length}/${items.length} decrypted ok, ${errors.length} errors`); - res.json({ results, errors }); - } catch (err: any) { - const traceId = randomUUID(); - console.error(`[seal/decrypt-batch] [${traceId}] error:`, err); - res.status(500).json({ error: "Internal server error", traceId }); - } -}); - -// ============================================================ -// POST /walrus/upload -// ============================================================ -// HIGH-13: /walrus/upload receives a base64-encoded SEAL ciphertext which can -// be up to ~87 KiB per 64 KiB plaintext (SEAL overhead + base64 ≈ 1.37×). -// The 10 MB ceiling matches the sidecar's original global Walrus limit and is -// well above any realistic single-memory upload size. -app.post("/walrus/upload", express.json({ limit: "10mb" }), async (req, res) => { - try { - const { - data, - keyIndex, - owner, - namespace, - packageId, - agentId, - epochs: rawEpochs = DEFAULT_WALRUS_EPOCHS, - } = req.body; - // LOW-17: Cap epochs at 5 to prevent accidental large storage purchases - const epochs = Math.min(Number(rawEpochs) || DEFAULT_WALRUS_EPOCHS, 5); - - if (!data || keyIndex === undefined) { - return res.status(400).json({ error: "Missing required fields: data, keyIndex" }); - } - - const privateKey = SERVER_SUI_PRIVATE_KEYS[keyIndex]; - if (!privateKey) { - return res.status(400).json({ error: `Invalid keyIndex: ${keyIndex}` }); - } - - // LOW-16: Validate packageId resembles a Sui address to prevent injection - if (packageId && !/^0x[0-9a-fA-F]{1,64}$/.test(packageId)) { - return res.status(400).json({ error: "Invalid packageId format" }); - } - - // MED-11: Validate owner address format - if (owner && !/^0x[0-9a-fA-F]{64}$/.test(owner)) { - return res.status(400).json({ error: "Invalid owner address format" }); - } - - // Decode signer - const { secretKey } = decodeSuiPrivateKey(privateKey); - const signer = Ed25519Keypair.fromSecretKey(secretKey); - - const signerAddress = signer.toSuiAddress(); - const blob = await runExclusiveBySigner(signerAddress, async () => { - const blobData = new Uint8Array(Buffer.from(data, "base64")); - - // writeBlobFlow (stateful: encode → register → upload → certify) - const flow = walrusClient.writeBlobFlow({ blob: blobData }); - await flow.encode(); - - const registerTx = flow.register({ - epochs, - // Server owns the blob initially (needed for certify step) - owner: signerAddress, - deletable: true, - // Store namespace + owner as on-chain metadata (queryable for restore) - attributes: { - ...(namespace ? { memwal_namespace: namespace } : {}), - ...(owner ? { memwal_owner: owner } : {}), - ...(packageId ? { memwal_package_id: packageId } : {}), - }, - }); - - // Patch: convert GasCoin intents → sender's SUI coins. - // Enoki rejects GasCoin as tx argument, but relay requires the tip. - // After patching, signer pays tip from own SUI; Enoki sponsors gas. - patchGasCoinIntents(registerTx); - const tipRecipient = await getUploadRelayTipAddress(); - const registerAllowedAddresses = dedupeAddresses([signerAddress, tipRecipient]); - const registerDigest = await executeWithEnokiSponsor(registerTx, signer, registerAllowedAddresses); - await suiClient.waitForTransaction({ digest: registerDigest }); - - await flow.upload({ digest: registerDigest }); - - const certifyTx = flow.certify(); - // Wait until certify tx is confirmed before returning this upload. - const certifyDigest = await executeWithEnokiSponsor(certifyTx, signer); - await suiClient.waitForTransaction({ digest: certifyDigest }); - - return flow.getBlob(); - }); - - // Extract objectId — handle both { id: "0x..." } and { id: { id: "0x..." } } - let blobObjectId: string | null = null; - const rawId = (blob.blobObject as any)?.id; - if (typeof rawId === 'string') { - blobObjectId = rawId; - } else if (rawId && typeof rawId === 'object' && typeof rawId.id === 'string') { - blobObjectId = rawId.id; - } - - // Walrus package for on-chain Move calls (from env-driven WALRUS_PACKAGE_ID) - const WALRUS_PKG = WALRUS_PACKAGE_ID; - - // Set on-chain metadata + transfer blob to user in a single transaction - if (owner && owner !== signerAddress && blobObjectId) { - try { - const metaTx = new Transaction(); - const blobArg = metaTx.object(blobObjectId); - - // Set memwal_namespace metadata on-chain - metaTx.moveCall({ - target: `${WALRUS_PKG}::blob::insert_or_update_metadata_pair`, - arguments: [ - blobArg, - metaTx.pure.string("memwal_namespace"), - metaTx.pure.string(namespace || "default"), - ], - typeArguments: [], - }); - - // Set memwal_owner - metaTx.moveCall({ - target: `${WALRUS_PKG}::blob::insert_or_update_metadata_pair`, - arguments: [ - blobArg, - metaTx.pure.string("memwal_owner"), - metaTx.pure.string(owner), - ], - typeArguments: [], - }); - - // Set memwal_package_id - if (packageId) { - metaTx.moveCall({ - target: `${WALRUS_PKG}::blob::insert_or_update_metadata_pair`, - arguments: [ - blobArg, - metaTx.pure.string("memwal_package_id"), - metaTx.pure.string(packageId), - ], - typeArguments: [], - }); - } - - // Set memwal_agent_id - if (agentId) { - metaTx.moveCall({ - target: `${WALRUS_PKG}::blob::insert_or_update_metadata_pair`, - arguments: [ - blobArg, - metaTx.pure.string("memwal_agent_id"), - metaTx.pure.string(agentId), - ], - typeArguments: [], - }); - } - - // Transfer blob to user - metaTx.transferObjects([blobArg], owner); - - const metaDigest = await executeWithEnokiSponsor(metaTx, signer, dedupeAddresses([signerAddress, owner])); - await suiClient.waitForTransaction({ digest: metaDigest }); - console.log(`[walrus/upload] metadata set + transferred blob ${blobObjectId} to owner (ns=${namespace})`); - } catch (metaErr: any) { - // LOW-14: Previously the metadata-set + transfer failure was swallowed - // and /walrus/upload returned 200 with the blob_id, leaving the blob - // owned by the server wallet and the client unable to observe the - // failure. We still can't delete the blob from Walrus (no delete - // primitive after certify), so at minimum we log loudly AND return - // 500 so the caller can react (retry / mark stored-but-not-owned). - console.error( - `[walrus/upload] metadata+transfer FAILED for blob_object=${blobObjectId} ` + - `ns=${namespace || "default"}: ${metaErr?.message || metaErr}` - ); - return res.status(500).json({ - error: "Blob uploaded but metadata/transfer to owner failed", - blobId: blob.blobId, - objectId: blobObjectId, - transferStatus: "failed", - }); - } - } - - res.json({ - blobId: blob.blobId, - objectId: blobObjectId, - transferStatus: "ok", - }); - } catch (err: any) { - const traceId = randomUUID(); - console.error(`[walrus/upload] [${traceId}] error:`, err); - res.status(500).json({ error: "Internal server error", traceId }); - } -}); - -// ============================================================ -// POST /walrus/query-blobs -// Query user's Walrus Blob objects from Sui chain, filter by namespace -// ============================================================ - -/** - * Fetch a dynamic field with retry + exponential backoff on 429 rate limit errors. - */ -async function getDynamicFieldWithRetry( - parentId: string, - fieldName: { type: string; value: number[] }, - maxRetries = 4, -): Promise { - let lastErr: any; - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - return await suiClient.getDynamicFieldObject({ - parentId, - name: fieldName, - }); - } catch (err: any) { - lastErr = err; - const msg = String(err?.message || err); - // Retry on 429 (rate limit) or 503 (service unavailable) - const isRetryable = msg.includes("429") || msg.includes("503") || msg.includes("rate"); - if (!isRetryable || attempt === maxRetries - 1) throw err; - const delayMs = 250 * Math.pow(2, attempt); // 250ms, 500ms, 1000ms, 2000ms - console.warn(`[query-blobs] getDynamicField 429/503 for ${parentId}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`); - await new Promise(r => setTimeout(r, delayMs)); - } - } - throw lastErr; -} - -/** - * Run async tasks with a bounded concurrency limit. - * Avoids overwhelming Sui RPC with too many parallel calls (→ 429). - */ -async function mapConcurrent( - items: T[], - concurrency: number, - fn: (item: T) => Promise, -): Promise { - const results: R[] = new Array(items.length); - let index = 0; - - async function worker() { - while (true) { - const i = index++; - if (i >= items.length) break; - results[i] = await fn(items[i]); - } - } - - const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); - await Promise.all(workers); - return results; -} - -app.post("/walrus/query-blobs", async (req, res) => { - try { - const { owner, namespace, packageId } = req.body; - if (!owner) { - return res.status(400).json({ error: "Missing required field: owner" }); - } - - // Walrus Blob type (derived from env-driven WALRUS_PACKAGE_ID) - const WALRUS_BLOB_TYPE = `${WALRUS_PACKAGE_ID}::blob::Blob`; - - // Step 1: Collect all raw blob objects (paginated, each page = 1 RPC call) - type RawBlobObj = { objectId: string; rawBlobId: string | number | null }; - const rawObjs: RawBlobObj[] = []; - let cursor: string | null | undefined = undefined; - let hasMore = true; - - while (hasMore) { - const result = await suiClient.getOwnedObjects({ - owner, - filter: { StructType: WALRUS_BLOB_TYPE }, - options: { showContent: true }, - cursor: cursor ?? undefined, - limit: 50, - }); - - for (const obj of result.data) { - if (!obj.data?.content || obj.data.content.dataType !== "moveObject") continue; - const fields = (obj.data.content as any).fields; - if (!fields) continue; - const rawBlobId = fields.blob_id ?? fields.blobId ?? null; - rawObjs.push({ objectId: obj.data.objectId, rawBlobId }); - } - - hasMore = result.hasNextPage; - cursor = result.nextCursor; - } - - console.log(`[query-blobs] found ${rawObjs.length} raw blob objects for owner=${owner}`); - - // Step 2: Fetch metadata for each blob with bounded concurrency (5 at a time) - // to avoid overwhelming Sui RPC and hitting 429 rate limits. - const METADATA_FIELD_NAME = { - type: "vector", - value: [109, 101, 116, 97, 100, 97, 116, 97], // b"metadata" - }; - - type BlobMeta = { - objectId: string; - rawBlobId: string | number | null; - blobNamespace: string; - blobOwner: string; - blobPackageId: string; - blobAgentId: string; - }; - - const metas: BlobMeta[] = await mapConcurrent(rawObjs, 5, async (obj) => { - let blobNamespace = "default"; - let blobOwner = ""; - let blobPackageId = ""; - let blobAgentId = ""; - - try { - const dynField = await getDynamicFieldWithRetry(obj.objectId, METADATA_FIELD_NAME); - - if (dynField.data?.content && dynField.data.content.dataType === "moveObject") { - const dynFields = (dynField.data.content as any).fields; - // Path: fields.value.fields.metadata.fields.contents[] - const contents = dynFields?.value?.fields?.metadata?.fields?.contents; - if (Array.isArray(contents)) { - for (const entry of contents) { - const key = entry?.fields?.key; - const value = entry?.fields?.value; - if (key === "memwal_namespace") blobNamespace = value; - if (key === "memwal_owner") blobOwner = value; - if (key === "memwal_package_id") blobPackageId = value; - if (key === "memwal_agent_id") blobAgentId = value; - } - } - } - } catch { - // No dynamic field = no metadata = use defaults - } - - return { ...obj, blobNamespace, blobOwner, blobPackageId, blobAgentId }; - }); - - // Step 3: Filter + convert blob IDs - const blobs: { blobId: string; objectId: string; namespace: string; packageId: string; agentId: string }[] = []; - - for (const meta of metas) { - // Filter by namespace if specified - if (namespace && meta.blobNamespace !== namespace) continue; - // Filter by packageId if specified - if (packageId && meta.blobPackageId !== packageId) continue; - - if (meta.rawBlobId) { - // blob_id from chain is a big integer (U256) — convert to base64url (little-endian!) - let blobIdStr = String(meta.rawBlobId); - if (/^\d+$/.test(blobIdStr) && blobIdStr.length > 20) { - try { - const bigInt = BigInt(blobIdStr); - const hex = bigInt.toString(16).padStart(64, '0'); - // Convert hex to bytes (big-endian), then REVERSE to little-endian - const bytesBE = hex.match(/.{2}/g)!.map(b => parseInt(b, 16)); - const bytesLE = new Uint8Array(bytesBE.reverse()); - blobIdStr = Buffer.from(bytesLE).toString('base64url'); - } catch { - // Keep as-is if conversion fails - } - } - blobs.push({ blobId: blobIdStr, objectId: meta.objectId, namespace: meta.blobNamespace, packageId: meta.blobPackageId, agentId: meta.blobAgentId }); - } - } - - console.log(`[query-blobs] returning ${blobs.length} blobs (filtered from ${rawObjs.length}) for owner=${owner} ns=${namespace || '*'}`); - res.json({ blobs, total: blobs.length }); - } catch (err: any) { - console.error(`[walrus/query-blobs] error: ${err.message || err}`); - res.status(500).json({ error: err.message || String(err) }); - } -}); - -// ============================================================ -// POST /sponsor — Create Enoki-sponsored transaction for frontend -// Frontend sends TransactionKind bytes + sender → returns sponsored { bytes, digest } -// ============================================================ -app.post("/sponsor", async (req, res) => { - try { - const { transactionBlockKindBytes, sender } = req.body; - if (!transactionBlockKindBytes || !sender) { - return res.status(400).json({ error: "Missing required fields: transactionBlockKindBytes, sender" }); - } - if (!enokiApiKey) { - return res.status(503).json({ error: "Enoki sponsorship is not configured (ENOKI_API_KEY missing)" }); - } - - // LOW-18: Redact full sender address (PII / deanonymisation) — log only - // a short prefix for correlation. Never log the full digest here either. - const senderPrefix = typeof sender === "string" ? sender.slice(0, 10) : "unknown"; - console.log(`[sponsor] creating sponsored tx for sender=${senderPrefix}...`); - const sponsored = await callEnoki("/transaction-blocks/sponsor", { - network: enokiNetwork, - transactionBlockKindBytes, - sender, - }); - - console.log(`[sponsor] sponsored tx created (digest_len=${sponsored.digest.length})`); - res.json(sponsored); // { bytes, digest } - } catch (err: any) { - console.error(`[sponsor] error: ${err.message || err}`); - res.status(500).json({ error: err.message || String(err) }); - } -}); - -// ============================================================ -// POST /sponsor/execute — Execute signed sponsored transaction -// Frontend sends { digest, signature } after user wallet signs → returns { digest } -// ============================================================ -app.post("/sponsor/execute", async (req, res) => { - try { - const { digest, signature } = req.body; - if (!digest || !signature) { - return res.status(400).json({ error: "Missing required fields: digest, signature" }); - } - if (!enokiApiKey) { - return res.status(503).json({ error: "Enoki sponsorship is not configured (ENOKI_API_KEY missing)" }); - } - - // LOW-15: Percent-encode digest before path interpolation. The digest is - // attacker-controlled when the sidecar is reached directly (no auth, - // S1 in audit) or via the Rust proxy which validates base58 but the - // sidecar must not rely on that. encodeURIComponent neutralises any - // path traversal (`..`), query injection (`?`), or fragment (`#`) - // payloads in the digest segment. - const encodedDigest = encodeURIComponent(digest); - const executed = await callEnoki( - `/transaction-blocks/sponsor/${encodedDigest}`, - { digest, signature } - ); - - // LOW-18: Redact digest from console logs — it's a high-cardinality - // value that ties log lines to individual user transactions. Log only - // a length indicator for diagnostics. - console.log(`[sponsor/execute] executed sponsored tx (digest_len=${digest.length})`); - res.json(executed); // { digest } - } catch (err: any) { - console.error(`[sponsor/execute] error: ${err.message || err}`); - res.status(500).json({ error: err.message || String(err) }); - } -}); - -// ============================================================ -// Start server -// ============================================================ - -const PORT = parseInt(process.env.SIDECAR_PORT || "9000", 10); -const HOST = process.env.SIDECAR_HOST || "127.0.0.1"; -app.listen(PORT, HOST, () => { - console.log(JSON.stringify({ - event: "sidecar_ready", - host: HOST, - port: PORT, - pid: process.pid, - })); -}); diff --git a/services/server/scripts/walrus-upload.ts b/services/server/scripts/walrus-upload.ts deleted file mode 100644 index 473d67b5..00000000 --- a/services/server/scripts/walrus-upload.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Walrus Upload Relay Script (multi-step flow) - * - * Uses the writeBlobFlow stateful API (encode → register → upload → certify) - * instead of writeBlob (one-shot). This avoids signer mismatch errors - * when existing Blob objects belong to a different wallet. - * - * Called by the Rust server as a subprocess. - * - * Usage: - * npx tsx walrus-upload.ts \ - * --data \ - * --private-key \ - * --owner <0x-sui-address> \ - * [--epochs ] - * - * Output (JSON to stdout): - * { "blobId": "...", "objectId": "..." } - * - * Errors are written to stderr with non-zero exit code. - */ - -import { WalrusClient } from "@mysten/walrus"; -import { SuiJsonRpcClient, getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; -import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; -import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; - -// ============================================================ -// Parse CLI arguments -// ============================================================ - -function parseArgs(): { - data: Buffer; - privateKey: string; - owner: string; - epochs: number; -} { - const args = process.argv.slice(2); - let data: string | undefined; - let privateKey: string | undefined; - let owner: string | undefined; - let epochs = 50; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case "--data": - data = args[++i]; - break; - case "--private-key": - privateKey = args[++i]; - break; - case "--owner": - owner = args[++i]; - break; - case "--epochs": - epochs = parseInt(args[++i], 10); - break; - case "--help": - console.log( - "usage: walrus-upload.ts --data --private-key --owner <0x...> [--epochs N]" - ); - process.exit(0); - } - } - - if (!data || !privateKey || !owner) { - console.error( - "error: required args: --data --private-key --owner <0x...>" - ); - process.exit(1); - } - - return { - data: Buffer.from(data, "base64"), - privateKey, - owner, - epochs, - }; -} - -// ============================================================ -// Main -// ============================================================ - -async function main() { - const { data, privateKey, owner, epochs } = parseArgs(); - - // Decode Sui private key (bech32 → keypair) - const { secretKey } = decodeSuiPrivateKey(privateKey); - const signer = Ed25519Keypair.fromSecretKey(secretKey); - - // Network config from env vars - const SUI_NETWORK = (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet"; - const WALRUS_UPLOAD_RELAY_URL = process.env.WALRUS_UPLOAD_RELAY_URL || ( - SUI_NETWORK === "testnet" - ? "https://upload-relay.testnet.walrus.space" - : "https://upload-relay.mainnet.walrus.space" - ); - - // Create Sui JSON-RPC client - const suiClient = new SuiJsonRpcClient({ - url: getJsonRpcFullnodeUrl(SUI_NETWORK), - network: SUI_NETWORK, - }); - - // Create WalrusClient with upload relay - const walrusClient = new WalrusClient({ - network: SUI_NETWORK, - suiClient: suiClient as any, - uploadRelay: { - host: WALRUS_UPLOAD_RELAY_URL, - sendTip: { max: 10_000_000 }, - }, - }); - - // writeBlobFlow is a stateful object — each step stores results internally - const flow = walrusClient.writeBlobFlow({ - blob: new Uint8Array(data), - }); - - // Step 1: Encode (Red Stuff encoding, stores internally) - await flow.encode(); - - // Step 2: Register blob on Sui → returns a Transaction - // Use signer address as owner so sender = signer (avoids mismatch). - // MemWal only needs the blobId to download/decrypt — blob ownership - // on Walrus doesn't affect the SEAL encryption/decryption flow. - const signerAddress = signer.toSuiAddress(); - const registerTx = flow.register({ - epochs, - owner: signerAddress, - deletable: true, - }); - - // Sign and execute the register transaction - const registerResult = await suiClient.signAndExecuteTransaction({ - signer, - transaction: registerTx, - }); - - // Step 3: Upload encoded data to relay - await flow.upload({ digest: registerResult.digest }); - - // Step 4: Certify blob on Sui → returns a Transaction - const certifyTx = flow.certify(); - - // Sign and execute the certify transaction - await suiClient.signAndExecuteTransaction({ - signer, - transaction: certifyTx, - }); - - // Get blob info from the flow - const blob = await flow.getBlob(); - - console.log(JSON.stringify({ - blobId: blob.blobId, - objectId: (blob.blobObject as any)?.id ?? null, - })); -} - -main().catch((err) => { - console.error(`walrus-upload error: ${err.message || err}`); - process.exit(1); -}); diff --git a/services/server/src/enoki.rs b/services/server/src/enoki.rs new file mode 100644 index 00000000..9959bc9c --- /dev/null +++ b/services/server/src/enoki.rs @@ -0,0 +1,265 @@ +//! Enoki client — sponsored Sui transactions over plain HTTPS (ENG-1700). +//! +//! Replaces the deleted Node sidecar's `/sponsor` and `/sponsor/execute` +//! proxies with a direct `reqwest` client to `api.enoki.mystenlabs.com`. +//! Bearer-authed with `ENOKI_API_KEY`. +//! +//! Endpoints used: +//! - `POST /v1/transaction-blocks/sponsor` +//! body: `{ network, transactionBlockKindBytes, sender, allowedAddresses?, allowedMoveCallTargets? }` +//! 200: `{ data: { bytes: base64, digest: base58 } }` +//! - `POST /v1/transaction-blocks/sponsor/{digest}` +//! body: `{ signature: base64 }` +//! 200: `{ data: { digest: base58 } }` + +use std::time::Duration; + +const ENOKI_BASE_URL: &str = "https://api.enoki.mystenlabs.com"; + +#[derive(Debug, thiserror::Error)] +pub enum EnokiError { + #[error("Enoki not configured (ENOKI_API_KEY unset)")] + NotConfigured, + #[error("Enoki auth failed (401)")] + Auth, + #[error("Enoki bad request: {0}")] + BadRequest(String), + #[error("Enoki rate-limited (429)")] + RateLimit, + #[error("Enoki server error ({status}): {body}")] + Server { status: u16, body: String }, + #[error("Enoki network: {0}")] + Network(String), + #[error("Enoki decode: {0}")] + Decode(String), +} + +impl EnokiError { + /// Map upstream Enoki error → HTTP status to return to our caller. + /// Masks server internals (no upstream body forwarded for 5xx). + pub fn to_status(&self) -> u16 { + match self { + EnokiError::NotConfigured => 503, + EnokiError::RateLimit => 503, + EnokiError::Auth => 502, + EnokiError::BadRequest(_) => 400, + EnokiError::Server { .. } => 502, + EnokiError::Network(_) => 502, + EnokiError::Decode(_) => 502, + } + } +} + +/// Successful response from `POST /v1/transaction-blocks/sponsor`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SponsorResult { + /// Sponsor-wrapped transaction bytes (base64). + pub bytes: String, + /// Transaction digest (base58, 43-44 chars). + pub digest: String, +} + +/// Successful response from `POST /v1/transaction-blocks/sponsor/{digest}`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ExecuteResult { + pub digest: String, +} + +/// Wrapper Enoki uses for all responses: `{ "data": { ... } }`. +#[derive(Debug, serde::Deserialize)] +struct EnokiEnvelope { + data: T, +} + +#[derive(Clone)] +pub struct EnokiClient { + api_key: Option, + network: String, + base_url: String, + http: reqwest::Client, +} + +impl EnokiClient { + pub fn new(api_key: Option, network: String) -> Self { + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client builder"); + Self { + api_key, + network, + base_url: ENOKI_BASE_URL.to_string(), + http, + } + } + + /// True when ENOKI_API_KEY is set. Caller can return 503 fast-path otherwise. + pub fn is_configured(&self) -> bool { + self.api_key.is_some() + } + + /// `POST /v1/transaction-blocks/sponsor` + /// + /// `tx_kind_bytes_b64` is the base64-encoded `TransactionKind` bytes + /// produced by the client (no gas, no sender). + /// + /// `allowed_addresses` is a per-call dynamic allow-list. When non-empty, + /// Enoki permits the listed addresses to receive `transfer_objects` + /// recipients in the sponsored tx — necessary for multi-tenant flows + /// where the recipient (a user wallet) is not pre-allow-listed at the + /// API-key level. Pass `&[]` to omit the field entirely. + pub async fn sponsor( + &self, + sender: &str, + tx_kind_bytes_b64: &str, + allowed_addresses: &[&str], + ) -> Result { + let api_key = self.api_key.as_deref().ok_or(EnokiError::NotConfigured)?; + let url = format!("{}/v1/transaction-blocks/sponsor", self.base_url); + + let mut body = serde_json::json!({ + "network": self.network, + "transactionBlockKindBytes": tx_kind_bytes_b64, + "sender": sender, + }); + if !allowed_addresses.is_empty() { + body["allowedAddresses"] = serde_json::Value::Array( + allowed_addresses + .iter() + .map(|s| serde_json::Value::String((*s).to_string())) + .collect(), + ); + } + + let resp = self + .http + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| EnokiError::Network(e.to_string()))?; + + let status = resp.status(); + let resp_text = resp + .text() + .await + .map_err(|e| EnokiError::Network(format!("read body: {}", e)))?; + + if status.is_success() { + let env: EnokiEnvelope = serde_json::from_str(&resp_text) + .map_err(|e| EnokiError::Decode(format!("sponsor envelope: {}", e)))?; + return Ok(env.data); + } + + Err(map_status_error(status.as_u16(), resp_text)) + } + + /// `POST /v1/transaction-blocks/sponsor/{digest}` + /// + /// `signature_b64` is the user's signature over the bytes returned by + /// `sponsor()`. Digest is percent-encoded into the URL path to prevent + /// path-traversal-like injection. + pub async fn sponsor_execute( + &self, + digest: &str, + signature_b64: &str, + ) -> Result { + let api_key = self.api_key.as_deref().ok_or(EnokiError::NotConfigured)?; + // PATH_SEGMENT encoding handles slashes, but digests are base58 so they + // contain only alphanumerics — encoding is purely defense-in-depth. + let digest_enc = percent_encoding::utf8_percent_encode( + digest, + percent_encoding::NON_ALPHANUMERIC, + ) + .to_string(); + let url = format!("{}/v1/transaction-blocks/sponsor/{}", self.base_url, digest_enc); + + let body = serde_json::json!({ "signature": signature_b64 }); + + let resp = self + .http + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| EnokiError::Network(e.to_string()))?; + + let status = resp.status(); + let resp_text = resp + .text() + .await + .map_err(|e| EnokiError::Network(format!("read body: {}", e)))?; + + if status.is_success() { + let env: EnokiEnvelope = serde_json::from_str(&resp_text) + .map_err(|e| EnokiError::Decode(format!("execute envelope: {}", e)))?; + return Ok(env.data); + } + + Err(map_status_error(status.as_u16(), resp_text)) + } +} + +fn map_status_error(status: u16, body: String) -> EnokiError { + match status { + 401 | 403 => EnokiError::Auth, + 429 => EnokiError::RateLimit, + 400..=499 => EnokiError::BadRequest(body), + 500..=599 => EnokiError::Server { status, body }, + _ => EnokiError::Server { status, body }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enoki_error_status_mapping() { + assert_eq!(EnokiError::NotConfigured.to_status(), 503); + assert_eq!(EnokiError::RateLimit.to_status(), 503); + assert_eq!(EnokiError::Auth.to_status(), 502); + assert_eq!(EnokiError::BadRequest("x".into()).to_status(), 400); + assert_eq!( + EnokiError::Server { + status: 500, + body: "x".into() + } + .to_status(), + 502 + ); + assert_eq!(EnokiError::Network("x".into()).to_status(), 502); + } + + #[test] + fn map_status_error_buckets() { + assert!(matches!(map_status_error(429, "x".into()), EnokiError::RateLimit)); + assert!(matches!(map_status_error(401, "x".into()), EnokiError::Auth)); + assert!(matches!(map_status_error(403, "x".into()), EnokiError::Auth)); + assert!(matches!( + map_status_error(400, "x".into()), + EnokiError::BadRequest(_) + )); + assert!(matches!( + map_status_error(500, "x".into()), + EnokiError::Server { .. } + )); + assert!(matches!( + map_status_error(503, "x".into()), + EnokiError::Server { .. } + )); + } + + #[test] + fn client_is_configured() { + let c = EnokiClient::new(None, "mainnet".into()); + assert!(!c.is_configured()); + + let c = EnokiClient::new(Some("k".into()), "mainnet".into()); + assert!(c.is_configured()); + } +} diff --git a/services/server/src/main.rs b/services/server/src/main.rs index 4ee484a9..0f7abd94 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -1,11 +1,15 @@ mod auth; mod db; +mod enoki; mod rate_limit; mod routes; mod seal; +mod seal_keyserver; mod sui; mod types; mod walrus; +mod walrus_onchain; +mod walrus_publisher; use axum::{extract::DefaultBodyLimit, middleware, routing::{get, post}, Router}; use std::net::SocketAddr; @@ -48,48 +52,13 @@ async fn main() { config.sponsor_rate_limit.per_hour, ); - // Start TS sidecar HTTP server (SEAL + Walrus operations) - let sidecar_url = config.sidecar_url.clone(); - tracing::info!(" sidecar: starting at {}", sidecar_url); - // Use SIDECAR_SCRIPTS_DIR if set (Docker), otherwise derive from CARGO_MANIFEST_DIR (local dev) - let scripts_dir = std::env::var("SIDECAR_SCRIPTS_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts")); - let mut sidecar_child = tokio::process::Command::new("npx") - .args(["tsx", "sidecar-server.ts"]) - .current_dir(&scripts_dir) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::inherit()) - .spawn() - .expect("Failed to start TS sidecar. Is Node.js installed?"); - - // Wait for sidecar to be ready (health check with retry) - // LOW-9: Set 30s timeout on HTTP client to prevent hanging LLM/Walrus requests + // ENG-1700: Native Rust SDKs replaced the TS sidecar. SEAL/Walrus/Enoki + // operations now run in-process (modules: seal, seal_keyserver, walrus, + // walrus_publisher, walrus_onchain, enoki). No subprocess to spawn. let http_client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .expect("Failed to build HTTP client"); - let health_url = format!("{}/health", sidecar_url); - let mut ready = false; - for attempt in 1..=30 { - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - match http_client.get(&health_url).send().await { - Ok(resp) if resp.status().is_success() => { - tracing::info!(" sidecar: ready (attempt {})", attempt); - ready = true; - break; - } - _ => { - if attempt % 5 == 0 { - tracing::debug!(" sidecar: waiting... (attempt {})", attempt); - } - } - } - } - if !ready { - sidecar_child.kill().await.ok(); - panic!("TS sidecar failed to start after 15s. Check scripts/sidecar-server.ts"); - } // Initialize database (PostgreSQL + pgvector) let db = VectorDb::new(&config.database_url) @@ -121,6 +90,19 @@ async fn main() { .expect("Failed to connect to Redis for rate limiting"); tracing::info!(" Redis: connected at {}", config.rate_limit.redis_url); + // Enoki client for sponsored Sui txs (Phase 4 of ENG-1700; replaces + // sidecar /sponsor + /sponsor/execute proxies). When ENOKI_API_KEY is + // unset, the client returns NotConfigured → /sponsor returns 503. + let enoki = crate::enoki::EnokiClient::new( + config.enoki_api_key.clone(), + config.enoki_network.clone(), + ); + if enoki.is_configured() { + tracing::info!(" enoki: configured (network={})", config.enoki_network); + } else { + tracing::warn!(" enoki: ENOKI_API_KEY unset — /sponsor will return 503"); + } + // Shared application state let state = Arc::new(AppState { db, @@ -130,6 +112,7 @@ async fn main() { key_pool, redis, fallback_rate_limit: tokio::sync::Mutex::new(crate::rate_limit::InMemoryFallback::default()), + enoki, }); // Spawn background task for cache eviction @@ -253,7 +236,7 @@ async fn main() { tracing::info!(" health: http://localhost:{}/health", config.port); tracing::info!(" api: http://localhost:{}/api/{{remember,recall,analyze}}", config.port); - // Graceful shutdown: kill sidecar when server stops + // Graceful shutdown let shutdown = async { tokio::signal::ctrl_c().await.ok(); tracing::info!("shutting down..."); @@ -263,8 +246,4 @@ async fn main() { .with_graceful_shutdown(shutdown) .await .expect("Server failed"); - - // Cleanup sidecar after shutdown - sidecar_child.kill().await.ok(); - tracing::info!("sidecar stopped"); } diff --git a/services/server/src/routes.rs b/services/server/src/routes.rs index 73b48460..ca7d429a 100644 --- a/services/server/src/routes.rs +++ b/services/server/src/routes.rs @@ -166,8 +166,6 @@ pub async fn remember( let embed_fut = generate_embedding(&state.http_client, &state.config, text); let encrypt_fut = seal::seal_encrypt( &state.http_client, - &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), text.as_bytes(), owner, &state.config.package_id, @@ -186,8 +184,6 @@ pub async fn remember( .ok_or_else(|| AppError::Internal("No Sui keys configured (set SERVER_SUI_PRIVATE_KEYS or SERVER_SUI_PRIVATE_KEY)".into()))?; let upload_result = walrus::upload_blob( &state.http_client, - &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), &encrypted, 50, owner, @@ -279,8 +275,6 @@ pub async fn recall( .map(|hit| { let walrus_client = &state.walrus_client; let http_client = &state.http_client; - let sidecar_url = state.config.sidecar_url.clone(); - let sidecar_secret = state.config.sidecar_secret.clone(); let blob_id = hit.blob_id.clone(); let distance = hit.distance; let credential = credential.clone(); @@ -305,8 +299,6 @@ pub async fn recall( // Decrypt using SEAL (via sidecar HTTP) match seal::seal_decrypt( http_client, - &sidecar_url, - sidecar_secret.as_deref(), &encrypted_data, &credential, &package_id, @@ -417,8 +409,6 @@ pub async fn remember_manual( let upload = walrus::upload_blob( &state.http_client, - &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), &encrypted_bytes, 50, owner, @@ -577,8 +567,7 @@ pub async fn analyze( // Embed + SEAL encrypt concurrently (independent operations) let embed_fut = generate_embedding(&state.http_client, &state.config, &fact_text); let encrypt_fut = seal::seal_encrypt( - &state.http_client, &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), + &state.http_client, fact_text.as_bytes(), &owner, &state.config.package_id, ); let (vector_result, encrypted_result) = tokio::join!(embed_fut, encrypt_fut); @@ -588,8 +577,6 @@ pub async fn analyze( // Upload to Walrus (via sidecar HTTP) let upload_result = walrus::upload_blob( &state.http_client, - &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), &encrypted, 50, &owner, @@ -1135,8 +1122,6 @@ pub async fn ask( .map(|hit| { let walrus_client = &state.walrus_client; let http_client = &state.http_client; - let sidecar_url = state.config.sidecar_url.clone(); - let sidecar_secret = state.config.sidecar_secret.clone(); let blob_id = hit.blob_id.clone(); let distance = hit.distance; let credential = credential.clone(); @@ -1159,8 +1144,6 @@ pub async fn ask( }; match seal::seal_decrypt( http_client, - &sidecar_url, - sidecar_secret.as_deref(), &encrypted_data, &credential, &package_id, @@ -1370,8 +1353,6 @@ pub async fn restore( ); let on_chain_blobs = walrus::query_blobs_by_owner( &state.http_client, - &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), owner, Some(namespace), Some(&state.config.package_id), @@ -1496,8 +1477,6 @@ pub async fn restore( let decrypt_results: Vec> = stream::iter(downloaded.into_iter()) .map(|(blob_id, encrypted_data)| { let http_client = &state.http_client; - let sidecar_url = state.config.sidecar_url.clone(); - let sidecar_secret = state.config.sidecar_secret.clone(); let credential = credential.clone(); // Use the package_id that was stored with this blob (supports contract upgrades) let package_id = blob_package_ids @@ -1508,8 +1487,6 @@ pub async fn restore( async move { match seal::seal_decrypt( http_client, - &sidecar_url, - sidecar_secret.as_deref(), &encrypted_data, &credential, &package_id, @@ -1604,27 +1581,13 @@ pub async fn restore( } // ============================================================ -// Enoki Sponsor Proxy — forwards FE requests to internal sidecar +// Enoki Sponsor Proxy — forwards client requests to api.enoki.mystenlabs.com // ============================================================ - -/// Map a non-2xx upstream status to a generic (status, message) pair. -/// -/// Never forward raw upstream bodies — they may contain API keys, internal -/// service names, or stack traces. The full response is logged server-side. -fn mask_upstream(status: u16) -> (axum::http::StatusCode, &'static str) { - match status { - 429 => ( - axum::http::StatusCode::SERVICE_UNAVAILABLE, - "Sponsor service temporarily overloaded", - ), - 401 | 403 => ( - axum::http::StatusCode::BAD_GATEWAY, - "Sponsor service misconfigured", - ), - 500..=599 => (axum::http::StatusCode::BAD_GATEWAY, "Sponsor service error"), - _ => (axum::http::StatusCode::BAD_REQUEST, "Sponsor request rejected"), - } -} +// +// `EnokiError::to_status()` (in `crate::enoki`) owns the upstream→client +// status mapping. Raw upstream bodies are never forwarded — they may +// contain API keys, internal service names, or stack traces. Full +// upstream detail is logged server-side via `tracing::error!`. fn json_error_response(status: axum::http::StatusCode, msg: &'static str) -> Response { Response::builder() @@ -1723,46 +1686,47 @@ pub async fn sponsor_proxy( } } - // Re-serialise only validated fields before forwarding. - let forwarded = serde_json::json!({ - "sender": req.sender, - "transactionBlockKindBytes": req.transaction_block_kind_bytes, - }); - - let url = format!("{}/sponsor", state.config.sidecar_url); - let mut req = state - .http_client - .post(&url) - .header("Content-Type", "application/json") - .json(&forwarded); - if let Some(secret) = state.config.sidecar_secret.as_deref() { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req - .send() + // ENG-1700 / Phase 4: call Enoki directly. The sidecar proxy used to wrap + // the same `https://api.enoki.mystenlabs.com` endpoint with a Bearer + // ENOKI_API_KEY; we now do that here. Wire-compatible response shape: + // `{ bytes, digest }` matches what callers (chatbot/noter) already parse. + // + // Forward `allowedAddresses` if the client included it — sidecar parity + // for the multi-tenant case where Enoki must approve a recipient wallet + // that isn't pre-allow-listed at API key level. + let allow_owned: Vec = req.allowed_addresses.clone().unwrap_or_default(); + let allow_refs: Vec<&str> = allow_owned.iter().map(String::as_str).collect(); + match state + .enoki + .sponsor(&req.sender, &req.transaction_block_kind_bytes, &allow_refs) .await - .map_err(|e| AppError::Internal(format!("Sponsor proxy failed: {}", e)))?; - - let upstream_status = resp.status(); - let resp_body = resp - .bytes() - .await - .map_err(|e| AppError::Internal(format!("Sponsor proxy read failed: {}", e)))?; - - if upstream_status.is_success() { - Ok(Response::builder() - .status(axum::http::StatusCode::from_u16(upstream_status.as_u16()).unwrap()) - .header("Content-Type", "application/json") - .body(Body::from(resp_body)) - .unwrap()) - } else { - tracing::error!( - "sponsor upstream error {}: {}", - upstream_status, - String::from_utf8_lossy(&resp_body) - ); - let (masked_status, masked_msg) = mask_upstream(upstream_status.as_u16()); - Ok(json_error_response(masked_status, masked_msg)) + { + Ok(result) => { + let body = serde_json::json!({ + "bytes": result.bytes, + "digest": result.digest, + }); + Ok(Response::builder() + .status(axum::http::StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap()) + } + Err(e) => { + // Log full upstream detail server-side; mask to caller. + tracing::error!("sponsor: enoki error: {}", e); + let masked = e.to_status(); + let msg = match masked { + 503 => "Sponsor service temporarily overloaded", + 502 => "Sponsor service error", + 400 => "Sponsor request rejected", + _ => "Sponsor service error", + }; + Ok(json_error_response( + axum::http::StatusCode::from_u16(masked).unwrap(), + msg, + )) + } } } @@ -1814,45 +1778,30 @@ pub async fn sponsor_execute_proxy( } } - let forwarded = serde_json::json!({ - "digest": req.digest, - "signature": req.signature, - }); - - let url = format!("{}/sponsor/execute", state.config.sidecar_url); - let mut req = state - .http_client - .post(&url) - .header("Content-Type", "application/json") - .json(&forwarded); - if let Some(secret) = state.config.sidecar_secret.as_deref() { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req - .send() - .await - .map_err(|e| AppError::Internal(format!("Sponsor execute proxy failed: {}", e)))?; - - let upstream_status = resp.status(); - let resp_body = resp - .bytes() - .await - .map_err(|e| AppError::Internal(format!("Sponsor execute proxy read failed: {}", e)))?; - - if upstream_status.is_success() { - Ok(Response::builder() - .status(axum::http::StatusCode::from_u16(upstream_status.as_u16()).unwrap()) - .header("Content-Type", "application/json") - .body(Body::from(resp_body)) - .unwrap()) - } else { - tracing::error!( - "sponsor/execute upstream error {}: {}", - upstream_status, - String::from_utf8_lossy(&resp_body) - ); - let (masked_status, masked_msg) = mask_upstream(upstream_status.as_u16()); - Ok(json_error_response(masked_status, masked_msg)) + // ENG-1700 / Phase 4: call Enoki directly (see sponsor_proxy). + match state.enoki.sponsor_execute(&req.digest, &req.signature).await { + Ok(result) => { + let body = serde_json::json!({ "digest": result.digest }); + Ok(Response::builder() + .status(axum::http::StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap()) + } + Err(e) => { + tracing::error!("sponsor/execute: enoki error: {}", e); + let masked = e.to_status(); + let msg = match masked { + 503 => "Sponsor service temporarily overloaded", + 502 => "Sponsor service error", + 400 => "Sponsor request rejected", + _ => "Sponsor service error", + }; + Ok(json_error_response( + axum::http::StatusCode::from_u16(masked).unwrap(), + msg, + )) + } } } @@ -2030,58 +1979,6 @@ mod more_tests { assert!(decoded.len() > 7000); // caller must reject this } - // ---- mask_upstream — must never leak internal details ---- - - #[test] - fn test_mask_upstream_429_to_503() { - let (status, msg) = mask_upstream(429); - assert_eq!(status, axum::http::StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(msg, "Sponsor service temporarily overloaded"); - } - - #[test] - fn test_mask_upstream_401_to_502() { - let (status, msg) = mask_upstream(401); - assert_eq!(status, axum::http::StatusCode::BAD_GATEWAY); - assert_eq!(msg, "Sponsor service misconfigured"); - } - - #[test] - fn test_mask_upstream_403_to_502() { - let (status, msg) = mask_upstream(403); - assert_eq!(status, axum::http::StatusCode::BAD_GATEWAY); - assert_eq!(msg, "Sponsor service misconfigured"); - } - - #[test] - fn test_mask_upstream_500_to_502() { - let (status, msg) = mask_upstream(500); - assert_eq!(status, axum::http::StatusCode::BAD_GATEWAY); - assert_eq!(msg, "Sponsor service error"); - } - - #[test] - fn test_mask_upstream_503_to_502() { - let (status, msg) = mask_upstream(503); - assert_eq!(status, axum::http::StatusCode::BAD_GATEWAY); - assert_eq!(msg, "Sponsor service error"); - } - - #[test] - fn test_mask_upstream_404_to_400() { - let (status, msg) = mask_upstream(404); - assert_eq!(status, axum::http::StatusCode::BAD_REQUEST); - assert_eq!(msg, "Sponsor request rejected"); - } - - #[test] - fn test_mask_upstream_returns_static_strings_only() { - // Verify no dynamic content leaks through for any common error code - for code in [400u16, 401, 403, 404, 422, 429, 500, 502, 503] { - let (_, msg) = mask_upstream(code); - assert!(!msg.is_empty(), "mask must always return a message"); - // Message must not look like it came from serde_json / reqwest - assert!(!msg.contains("Error"), "raw error strings must not leak"); - } - } + // ---- Enoki error → client status mapping owned by `crate::enoki`. + // See `enoki::tests::enoki_error_status_mapping`. ---- } diff --git a/services/server/src/seal.rs b/services/server/src/seal.rs index 1657c30c..7570f674 100644 --- a/services/server/src/seal.rs +++ b/services/server/src/seal.rs @@ -1,7 +1,76 @@ -use crate::types::{AppError, AuthInfo, SidecarError}; +//! SEAL threshold encryption — native Rust implementation (ENG-1700). +//! +//! In-process replacement for the deleted TS sidecar's `/seal/encrypt`, +//! `/seal/decrypt`, and `/seal/decrypt-batch` HTTP routes. Behaves +//! identically (1:1 parity). +//! +//! Pipeline overview (decrypt path, mirroring the TS SDK): +//! +//! 1. **Resolve credential** to a session keypair + signed `Certificate`. +//! - `DelegateKey` (legacy hex / `suiprivkey1...` bech32): we hold the +//! delegate's private key, generate a fresh session keypair, build the +//! `signed_message(packageId, session_pk, creation_time_ms, ttl_min)`, +//! sign with the delegate key, wrap as `UserSignature::Simple`. +//! - `Session` (modern, x-seal-session header): the SDK has already +//! done the work — we import the exported session-key envelope. The +//! JSON shape is `{address, packageId, mvrName, creationTimeMs, +//! ttlMin, personalMessageSignature, sessionKey}` — see +//! `resolve_session_envelope` for details. The user's wallet +//! signature inside the envelope is forwarded to the key servers +//! verbatim; we never need the user's wallet private key. +//! 2. **Parse `EncryptedObject`** to recover the SEAL `id` (the field that +//! was passed to `seal_encrypt` and the chain's `seal_approve` policy). +//! 3. **Build a `seal_approve` PTB** (one MoveCall per unique id) targeting +//! `{packageId}::account::seal_approve(id, account)`. +//! 4. **ElGamal ephemeral keypair** for receiving wrapped server keys. +//! 5. **`signed_request`**: BCS-pack the (ptb, enc_key, enc_verification_key) +//! triple, sign it with the *session* private key. +//! 6. **Resolve the key-server committee** from chain (cached). For each +//! server, POST `FetchKeyRequest` JSON to `/v1/fetch_key` in parallel. +//! 7. **Threshold check**: need ≥ threshold successful responses. +//! 8. **`decrypt_seal_responses`**: ElGamal-decrypt the wrapped IBE keys. +//! 9. **`seal_decrypt_object`**: combine threshold shares → AES-decrypt the +//! payload. +//! +//! Encrypt is the inverse: resolve committee, call `crypto::seal_encrypt` +//! with `EncryptionInput::Hmac256Ctr` (the cipher mode the TS SDK defaults +//! to), BCS-encode the resulting `EncryptedObject` and return. + +use std::collections::{HashMap, HashSet}; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use crypto::{ + ibe::PublicKey as IBEPublicKey, EncryptionInput, IBEPublicKeys, ObjectID, +}; +use ed25519_dalek::Signer as _; +use fastcrypto::{ + ed25519::{Ed25519PublicKey, Ed25519Signature}, + traits::ToFromBytes, +}; +use rand::thread_rng; +use seal_sdk::{ + decrypt_seal_responses, genkey, seal_decrypt_object, seal_encrypt as crypto_seal_encrypt, + signed_message, signed_request, + types::{Certificate, ElGamalPublicKey, ElgamalVerificationKey, FetchKeyRequest}, + EncryptedObject, +}; +use sui_sdk_types::{ + Address, Argument, Command, Digest, Ed25519PublicKey as SuiEd25519PublicKey, Ed25519Signature as SuiEd25519Signature, + Identifier, Input, MoveCall, Mutability, ObjectReference, ProgrammableTransaction, SharedInput, + SimpleSignature, UserSignature, +}; + +use crate::seal_keyserver::{ + fetch_key, key_server_ids_from_env, resolve_committee, seal_threshold_from_env, + KeyServerInfo, +}; +use crate::types::{AppError, AuthInfo}; + +// ============================================================ +// Public types (preserved API) +// ============================================================ -/// Credential used to authorize a SEAL decrypt request against the sidecar. +/// Credential used to authorize a SEAL decrypt request. /// /// ENG-1697: `Session` (an exported `SessionKey`, built on the client) is /// preferred. `DelegateKey` is the legacy path where the SDK transmits the @@ -34,159 +103,1166 @@ impl SealCredential { } } -/// Request/response types for sidecar HTTP API -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct SealEncryptRequest { - data: String, - owner: String, - package_id: String, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SealEncryptResponse { - encrypted_data: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct SealDecryptRequest { - data: String, - package_id: String, - account_id: String, -} +// ============================================================ +// Native SEAL pipeline +// ============================================================ -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SealDecryptResponse { - decrypted_data: String, +/// SEAL key ID used by `crypto::seal_encrypt` and the on-chain +/// `seal_approve` policy. Mirrors the TS sidecar (`id: owner` ⇒ raw owner +/// address bytes), so encrypt/decrypt agree on what bytes go through the +/// IBE hash. +/// +/// `owner_address` here is a `0x`-prefixed hex string (40 bytes after the +/// prefix); we strip the prefix and hex-decode to raw bytes. +fn id_bytes_from_owner(owner_address: &str) -> Result, AppError> { + let s = owner_address.trim_start_matches("0x"); + if s.is_empty() { + return Err(AppError::BadRequest("owner address is empty".into())); + } + hex::decode(s).map_err(|e| AppError::BadRequest(format!("invalid owner address: {}", e))) } -/// Encrypt plaintext using SEAL threshold encryption via HTTP sidecar. +/// Encrypt `data` using SEAL threshold encryption. /// -/// Calls the long-lived sidecar server at `POST /seal/encrypt`. -/// The ciphertext is bound to the user's address via SEAL key ID. -/// -/// Returns: SEAL encrypted bytes +/// Returns BCS-serialized `EncryptedObject` bytes — same wire format the +/// TS SDK produced with `sealClient.encrypt(...)`. pub async fn seal_encrypt( client: &reqwest::Client, - sidecar_url: &str, - sidecar_secret: Option<&str>, data: &[u8], owner_address: &str, package_id: &str, ) -> Result, AppError> { - let url = format!("{}/seal/encrypt", sidecar_url); - let data_b64 = BASE64.encode(data); - - let mut req = client - .post(&url) - .json(&SealEncryptRequest { - data: data_b64, - owner: owner_address.to_string(), - package_id: package_id.to_string(), - }); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); + let id = id_bytes_from_owner(owner_address)?; + let pkg = parse_address(package_id, "package_id")?; + let pkg_id: ObjectID = pkg; + + let key_server_ids = key_server_ids_from_env(); + if key_server_ids.is_empty() { + return Err(AppError::Internal( + "SEAL_KEY_SERVERS env var is empty — cannot encrypt".into(), + )); } - let resp = req - .send() - .await - .map_err(|e| { - AppError::Internal(format!("Sidecar seal/encrypt request failed: {}. Is the sidecar running?", e)) - })?; + let threshold = seal_threshold_from_env(); + let sui_rpc_url = sui_rpc_url_from_env(); - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - if let Ok(err) = serde_json::from_str::(&body) { - return Err(AppError::Internal(format!("seal encrypt failed: {}", err.error))); - } - return Err(AppError::Internal(format!("seal encrypt failed: {}", body))); + let committee = resolve_committee(client, &sui_rpc_url, &key_server_ids) + .await + .map_err(|e| AppError::Internal(format!("seal encrypt: resolve committee: {}", e)))?; + if (threshold as usize) > committee.len() || threshold == 0 { + return Err(AppError::Internal(format!( + "seal encrypt: invalid threshold {} for committee of {}", + threshold, + committee.len() + ))); } - let result: SealEncryptResponse = resp.json().await.map_err(|e| { - AppError::Internal(format!("Failed to parse seal/encrypt response: {}", e)) - })?; + let server_object_ids: Vec = committee.iter().map(|c| c.object_id).collect(); + let public_keys: Vec = committee.iter().map(|c| c.public_key).collect(); - let encrypted_bytes = BASE64.decode(&result.encrypted_data).map_err(|e| { - AppError::Internal(format!("Failed to decode encrypted base64: {}", e)) - })?; + // Hmac256Ctr matches the TS SDK default cipher mode (the seal-sdk crypto + // crate offers Aes256Gcm + Hmac256Ctr; @mysten/seal uses HMAC-CTR by + // default — see node_modules/@mysten/seal/dist/encrypt.mjs). + let (encrypted_object, _dem_key) = crypto_seal_encrypt( + pkg_id, + id, + server_object_ids, + &IBEPublicKeys::BonehFranklinBLS12381(public_keys), + threshold, + EncryptionInput::Hmac256Ctr { + data: data.to_vec(), + aad: None, + }, + ) + .map_err(|e| AppError::Internal(format!("seal encrypt: crypto error: {}", e)))?; + + let encrypted_bytes = bcs::to_bytes(&encrypted_object) + .map_err(|e| AppError::Internal(format!("seal encrypt: bcs encode: {}", e)))?; tracing::info!( - "seal encrypt ok: {} bytes -> {} encrypted bytes", + "seal encrypt ok: {} bytes -> {} encrypted bytes (threshold={}, servers={})", data.len(), - encrypted_bytes.len() + encrypted_bytes.len(), + threshold, + committee.len(), ); - Ok(encrypted_bytes) } -/// Decrypt SEAL-encrypted data via the sidecar. -/// -/// Calls `POST /seal/decrypt` on the long-lived sidecar server. The -/// credential (ENG-1697) is either an exported SessionKey token or a -/// legacy delegate private key. The client must have authority for -/// `seal_approve` against the given `account_id`. +/// Decrypt one SEAL-encrypted blob. /// -/// Returns: decrypted plaintext bytes. +/// `credential` is either a delegate private key or an exported +/// `@mysten/seal` SessionKey envelope (`x-seal-session` header). pub async fn seal_decrypt( client: &reqwest::Client, - sidecar_url: &str, - sidecar_secret: Option<&str>, encrypted_data: &[u8], credential: &SealCredential, package_id: &str, account_id: &str, ) -> Result, AppError> { - let url = format!("{}/seal/decrypt", sidecar_url); - let data_b64 = BASE64.encode(encrypted_data); - - let mut req = client - .post(&url) - .json(&SealDecryptRequest { - data: data_b64, - package_id: package_id.to_string(), - account_id: account_id.to_string(), - }); - req = match credential { - SealCredential::Session(s) => req.header("x-seal-session", s), - SealCredential::DelegateKey(k) => req.header("x-delegate-key", k), - }; - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); + let plaintexts = + seal_decrypt_batch(client, vec![encrypted_data], credential, package_id, account_id) + .await?; + let mut iter = plaintexts.into_iter(); + let first = iter.next().ok_or_else(|| { + AppError::Internal("seal decrypt: batch returned empty result".into()) + })?; + first.map_err(AppError::Internal) +} + +/// Decrypt many SEAL-encrypted blobs in one round-trip. +/// +/// Builds **one** `seal_approve` PTB containing one MoveCall per *unique* +/// SEAL id, makes **one** `/v1/fetch_key` call to each key server, then +/// uses the cached IBE user-secret-keys to decrypt each blob locally. +/// Results are returned in the same order as `items`. +#[allow(unused_variables)] +pub async fn seal_decrypt_batch( + client: &reqwest::Client, + items: Vec<&[u8]>, + credential: &SealCredential, + package_id: &str, + account_id: &str, +) -> Result, String>>, AppError> { + if items.is_empty() { + return Ok(Vec::new()); } - let resp = req - .send() + + // ── 1. Parse encrypted objects + collect unique SEAL ids ─────────── + let mut parsed: Vec> = Vec::with_capacity(items.len()); + for bytes in &items { + match bcs::from_bytes::(bytes) { + Ok(eo) => parsed.push(Ok(eo)), + Err(e) => parsed.push(Err(format!("EncryptedObject parse: {}", e))), + } + } + // Unique ids in stable order (so the PTB build is deterministic). + let mut seen: HashSet> = HashSet::new(); + let mut unique_ids: Vec> = Vec::new(); + for eo in parsed.iter().flatten() { + if seen.insert(eo.id.clone()) { + unique_ids.push(eo.id.clone()); + } + } + if unique_ids.is_empty() { + // Every item failed to parse — propagate one error per item. + return Ok(parsed + .into_iter() + .map(|r| r.map(|_| Vec::new()).map_err(|e| e)) + .collect::>()); + } + + // ── 2. Resolve credential → (session_kp, certificate) ────────────── + let pkg_addr = parse_address(package_id, "package_id")?; + let account_addr = parse_address(account_id, "account_id")?; + let resolved = resolve_credential_to_session(client, credential, &pkg_addr).await?; + + // ── 3. Resolve committee + threshold ─────────────────────────────── + let key_server_ids = key_server_ids_from_env(); + if key_server_ids.is_empty() { + return Err(AppError::Internal( + "SEAL_KEY_SERVERS env var is empty — cannot decrypt".into(), + )); + } + let threshold = seal_threshold_from_env(); + let sui_rpc_url = sui_rpc_url_from_env(); + + let committee = resolve_committee(client, &sui_rpc_url, &key_server_ids) .await - .map_err(|e| { - AppError::Internal(format!("Sidecar seal/decrypt request failed: {}. Is the sidecar running?", e)) - })?; + .map_err(|e| AppError::Internal(format!("seal decrypt: resolve committee: {}", e)))?; + if (threshold as usize) > committee.len() || threshold == 0 { + return Err(AppError::Internal(format!( + "seal decrypt: invalid threshold {} for committee of {}", + threshold, + committee.len() + ))); + } + + // ── 4. Build seal_approve PTB (one call per unique id) ───────────── + let ptb = build_seal_approve_ptb(pkg_addr, account_addr, &unique_ids, client, &sui_rpc_url).await?; + + // ── 5. ElGamal ephemeral keypair + signed request ────────────────── + let (eg_sk, eg_pk, eg_vk): ( + seal_sdk::types::ElGamalSecretKey, + ElGamalPublicKey, + ElgamalVerificationKey, + ) = genkey(&mut thread_rng()); + let request_bytes = signed_request(&ptb, &eg_pk, &eg_vk); + // Sign request with session keypair using ed25519-dalek (raw Ed25519, + // no intent prefix — matches what the key server verifies). + let sig_bytes = resolved + .session_signing_key + .sign(&request_bytes) + .to_bytes(); + let request_signature = Ed25519Signature::from_bytes(&sig_bytes) + .map_err(|e| AppError::Internal(format!("seal decrypt: signature encode: {}", e)))?; + + // ── 6. Build FetchKeyRequest body (BCS PTB → base64) ─────────────── + let ptb_b64 = BASE64.encode( + bcs::to_bytes(&ptb) + .map_err(|e| AppError::Internal(format!("seal decrypt: ptb bcs: {}", e)))?, + ); + let fetch_key_request = FetchKeyRequest { + ptb: ptb_b64, + enc_key: eg_pk.clone(), + enc_verification_key: eg_vk.clone(), + request_signature, + certificate: resolved.certificate.clone(), + }; + let body_json = fetch_key_request + .to_json_string() + .map_err(|e| AppError::Internal(format!("seal decrypt: fetch req json: {}", e)))?; + + // ── 7. Fan out POST /v1/fetch_key ────────────────────────────────── + let server_pk_map: HashMap = committee + .iter() + .map(|c| (c.object_id, c.public_key)) + .collect(); - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - if let Ok(err) = serde_json::from_str::(&body) { - return Err(AppError::Internal(format!("seal decrypt failed: {}", err.error))); + let server_responses = parallel_fetch_keys(client, &committee, &body_json, threshold).await?; + + // ── 8. ElGamal-decrypt the wrapped IBE keys ──────────────────────── + let cached_keys = decrypt_seal_responses(&eg_sk, &server_responses, &server_pk_map) + .map_err(|e| AppError::Internal(format!("seal decrypt: elgamal decrypt: {}", e)))?; + + // ── 9. Local seal_decrypt for each blob ──────────────────────────── + let mut out: Vec, String>> = Vec::with_capacity(items.len()); + for parsed_one in parsed { + match parsed_one { + Err(e) => out.push(Err(e)), + Ok(encrypted_object) => match seal_decrypt_object( + &encrypted_object, + &cached_keys, + &server_pk_map, + ) { + Ok(plaintext) => out.push(Ok(plaintext)), + Err(e) => out.push(Err(format!("seal decrypt: {}", e))), + }, } - return Err(AppError::Internal(format!("seal decrypt failed: {}", body))); } - let result: SealDecryptResponse = resp.json().await.map_err(|e| { - AppError::Internal(format!("Failed to parse seal/decrypt response: {}", e)) + let ok_count = out.iter().filter(|r| r.is_ok()).count(); + tracing::info!( + "seal decrypt batch ok: {} of {} blobs (committee_size={}, threshold={})", + ok_count, + items.len(), + committee.len(), + threshold, + ); + Ok(out) +} + +// ============================================================ +// Helpers — credential resolution +// ============================================================ + +/// Resolved credential: session signing key + certificate ready to embed in +/// a `FetchKeyRequest`. +struct ResolvedCredential { + /// Ephemeral Ed25519 *session* signing key (used to sign each + /// `signed_request`). For the delegate-key path this is freshly + /// generated; for the session path it's deserialized from the export. + session_signing_key: ed25519_dalek::SigningKey, + /// Pre-built certificate (signed by the user / delegate). + certificate: Certificate, +} + +impl std::fmt::Debug for ResolvedCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResolvedCredential") + .field("session_signing_key", &"") + .field("certificate.user", &self.certificate.user) + .finish() + } +} + +async fn resolve_credential_to_session( + _client: &reqwest::Client, + credential: &SealCredential, + package_id: &Address, +) -> Result { + match credential { + SealCredential::DelegateKey(k) => resolve_delegate_key(k, package_id), + SealCredential::Session(envelope) => resolve_session_envelope(envelope), + } +} + +/// Import an exported `SessionKey` (the JSON envelope produced by +/// `@mysten/seal`'s `SessionKey.export()`, base64-encoded for the +/// `x-seal-session` header) into a `ResolvedCredential` ready to be embedded +/// in a `FetchKeyRequest`. +/// +/// The TS export shape is (verbatim from +/// `node_modules/@mysten/seal/dist/session-key.mjs::export()` — +/// SDK v1.1.1): +/// +/// ```text +/// { +/// "address": string, // user wallet address (0x...) +/// "packageId": string, // 0x... package id +/// "mvrName": string | null, +/// "creationTimeMs": number, // u64 ms since epoch +/// "ttlMin": number, // u16 minutes +/// "personalMessageSignature": string, // base64 GenericSignature +/// "sessionKey": string // suiprivkey1... bech32 (Ed25519 secret) +/// } +/// ``` +/// +/// The `personalMessageSignature` is a base64-encoded `GenericSignature` +/// (flag-prefixed: `0x00 || sig(64) || pubkey(32)` for Ed25519). It signs +/// the `getPersonalMessage()` bytes — i.e. the same string the Rust +/// `seal_sdk::signed_message` produces. We trust the Mysten key servers to +/// verify the signature; here we only parse it into a `UserSignature` so +/// the `Certificate` is wire-ready. +fn resolve_session_envelope(token_b64: &str) -> Result { + // 1. base64 → utf8 JSON. + let raw = BASE64 + .decode(token_b64.trim()) + .map_err(|e| AppError::BadRequest(format!("invalid x-seal-session: base64 decode: {}", e)))?; + let json_str = std::str::from_utf8(&raw).map_err(|e| { + AppError::BadRequest(format!("invalid x-seal-session: not utf-8: {}", e)) + })?; + let v: serde_json::Value = serde_json::from_str(json_str).map_err(|e| { + AppError::BadRequest(format!("invalid x-seal-session: not json: {}", e)) })?; - let decrypted_bytes = BASE64.decode(&result.decrypted_data).map_err(|e| { - AppError::Internal(format!("Failed to decode decrypted base64: {}", e)) + // 2. Pull required fields. + let address_str = v + .get("address") + .and_then(|x| x.as_str()) + .ok_or_else(|| AppError::BadRequest("invalid x-seal-session: missing 'address'".into()))?; + let creation_time_ms = v + .get("creationTimeMs") + .and_then(|x| x.as_u64()) + .ok_or_else(|| { + AppError::BadRequest("invalid x-seal-session: missing/bad 'creationTimeMs'".into()) + })?; + let ttl_min_u64 = v + .get("ttlMin") + .and_then(|x| x.as_u64()) + .ok_or_else(|| { + AppError::BadRequest("invalid x-seal-session: missing/bad 'ttlMin'".into()) + })?; + if ttl_min_u64 > u16::MAX as u64 { + return Err(AppError::BadRequest(format!( + "invalid x-seal-session: ttlMin {} exceeds u16::MAX", + ttl_min_u64 + ))); + } + let ttl_min = ttl_min_u64 as u16; + let session_priv_str = v + .get("sessionKey") + .and_then(|x| x.as_str()) + .ok_or_else(|| { + AppError::BadRequest("invalid x-seal-session: missing 'sessionKey'".into()) + })?; + // `personalMessageSignature` is technically optional in the TS type + // (you can build a SessionKey without one), but the SDK always sets it + // before exporting, and the key servers reject a Certificate without + // it. Treat absence as a 400. + let pms_b64 = v + .get("personalMessageSignature") + .and_then(|x| x.as_str()) + .ok_or_else(|| { + AppError::BadRequest( + "invalid x-seal-session: missing 'personalMessageSignature' (the SDK must call setPersonalMessageSignature before export)".into(), + ) + })?; + // mvrName: TS exports it as `mvrName` (camelCase). May be null/undefined. + let mvr_name = v + .get("mvrName") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + + // 3. Parse the user wallet address. + let user = Address::from_hex(address_str).map_err(|e| { + AppError::BadRequest(format!( + "invalid x-seal-session: bad address '{}': {}", + address_str, e + )) })?; - tracing::info!( - "seal decrypt ok: {} encrypted bytes -> {} decrypted bytes", - encrypted_data.len(), - decrypted_bytes.len() + // 4. Decode the session private key (always `suiprivkey1...` bech32 from + // the SDK — `Ed25519Keypair.getSecretKey()` always returns bech32). + // Reuse the existing decoder so hex would also work if a custom client + // sent it that way. + let session_sk_bytes = decode_delegate_private_key(session_priv_str).map_err(|e| match e { + AppError::BadRequest(s) => { + AppError::BadRequest(format!("invalid x-seal-session sessionKey: {}", s)) + } + other => other, + })?; + let session_signing = ed25519_dalek::SigningKey::from_bytes(&session_sk_bytes); + + // 5. Derive session_vk (public key) from the private key. + let session_pubkey_bytes = session_signing.verifying_key().to_bytes(); + let session_vk = Ed25519PublicKey::from_bytes(&session_pubkey_bytes).map_err(|e| { + AppError::Internal(format!("seal session: vk encode: {}", e)) + })?; + + // 6. Parse the user's personalMessageSignature. The TS SDK stores it as + // a base64 `GenericSignature` (the same wire format + // `UserSignature::to_base64()` produces and `from_base64()` consumes). + // We deliberately do NOT verify it here — the Mysten key servers + // re-verify `signature` against `signed_message(packageId, session_vk, + // creation_time, ttl_min)` on every fetch_key call. Local verification + // would also require a Sui RPC roundtrip for zkLogin/multisig + // signers (see verifyPersonalMessageSignature in the TS SDK). + let user_signature = UserSignature::from_base64(pms_b64).map_err(|e| { + AppError::BadRequest(format!( + "invalid x-seal-session: personalMessageSignature parse: {}", + e + )) + })?; + + // 7. Build the certificate the key server expects. + let certificate = Certificate { + user, + session_vk, + creation_time: creation_time_ms, + ttl_min, + signature: user_signature, + mvr_name, + }; + + Ok(ResolvedCredential { + session_signing_key: session_signing, + certificate, + }) +} + +fn resolve_delegate_key(key_str: &str, package_id: &Address) -> Result { + let delegate_sk_bytes = decode_delegate_private_key(key_str)?; + let delegate_signing = ed25519_dalek::SigningKey::from_bytes(&delegate_sk_bytes); + let delegate_verifying = delegate_signing.verifying_key(); + let delegate_pubkey_sui = SuiEd25519PublicKey::new(delegate_verifying.to_bytes()); + let delegate_address = derive_ed25519_sui_address(&delegate_verifying.to_bytes()); + + // Generate a fresh session keypair. This is the keypair that signs the + // `signed_request` for each call to a key server — it never leaves the + // server process, and its public key is recorded on the certificate. + use rand::RngCore as _; + let mut session_seed = [0u8; 32]; + thread_rng().fill_bytes(&mut session_seed); + let session_signing = ed25519_dalek::SigningKey::from_bytes(&session_seed); + let session_pubkey_bytes = session_signing.verifying_key().to_bytes(); + let session_vk_fc = Ed25519PublicKey::from_bytes(&session_pubkey_bytes).map_err(|e| { + AppError::Internal(format!("seal: session vk encode: {}", e)) + })?; + + let creation_time_ms = chrono::Utc::now().timestamp_millis() as u64; + let ttl_min: u16 = 5; + + // The `signed_message` format is fixed by seal-sdk and the key server + // validates it byte-for-byte (see seal-sdk lib.rs::signed_message). + let msg = signed_message( + package_id.to_string(), + &session_vk_fc, + creation_time_ms, + ttl_min, ); - Ok(decrypted_bytes) + // Sui personal-message signing: hash the bcs-prefixed `PersonalMessage` + // intent. We delegate to the dalek key directly; the seal-sdk decodes + // the resulting `UserSignature::Simple` against the same prefix. The + // simplest path is to sign the *raw* personal message bytes and let the + // server's `verify_personal_message` apply the prefix during checking. + // + // For the delegate path the trust chain is "delegate signed this + // session-vk's authority"; the server-side `seal_approve` uses + // `tx_context::sender == delegate_addr` because we set the sender to + // the delegate's address via the certificate. + let personal_msg_bytes = msg.as_bytes().to_vec(); + let signature_raw = delegate_signing.sign(&personal_msg_bytes).to_bytes(); + let signature_sdk = SuiEd25519Signature::new(signature_raw); + let user_signature = UserSignature::Simple(SimpleSignature::Ed25519 { + signature: signature_sdk, + public_key: delegate_pubkey_sui, + }); + + let certificate = Certificate { + user: delegate_address, + session_vk: session_vk_fc, + creation_time: creation_time_ms, + ttl_min, + signature: user_signature, + mvr_name: None, + }; + + Ok(ResolvedCredential { + session_signing_key: session_signing, + certificate, + }) +} + +/// Decode a delegate private key string (hex 64 or `suiprivkey1...` bech32) +/// into the raw 32-byte Ed25519 secret key. +fn decode_delegate_private_key(key_str: &str) -> Result<[u8; 32], AppError> { + let key_str = key_str.trim(); + if key_str.starts_with("suiprivkey") { + let (hrp, data, _variant) = bech32::decode(key_str) + .map_err(|e| AppError::BadRequest(format!("delegate key bech32: {}", e)))?; + if hrp != "suiprivkey" { + return Err(AppError::BadRequest(format!( + "delegate key wrong HRP: {}", + hrp + ))); + } + use bech32::FromBase32; + let bytes = Vec::::from_base32(&data) + .map_err(|e| AppError::BadRequest(format!("delegate key base32: {}", e)))?; + if bytes.len() != 33 { + return Err(AppError::BadRequest(format!( + "delegate key bech32 payload length {}, expected 33", + bytes.len() + ))); + } + if bytes[0] != 0x00 { + return Err(AppError::BadRequest(format!( + "delegate key scheme flag 0x{:02x}, only Ed25519 (0x00) is supported", + bytes[0] + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes[1..33]); + Ok(out) + } else { + // Hex path. Validate both length AND charset before parsing — keeps + // accidentally-leaked secrets out of error messages and matches the + // TS sidecar's input rejection. + if key_str.len() != 64 { + return Err(AppError::BadRequest(format!( + "delegate key hex must be 64 chars, got {}", + key_str.len() + ))); + } + if !key_str.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AppError::BadRequest( + "delegate key hex contains non-hex chars".into(), + )); + } + let mut out = [0u8; 32]; + hex::decode_to_slice(key_str, &mut out).map_err(|e| { + AppError::BadRequest(format!("delegate key hex decode: {}", e)) + })?; + Ok(out) + } +} + +/// Derive a Sui address from an Ed25519 public key. Uses sui-sdk-types' +/// built-in derivation: `address = blake2b-256(0x00 || pubkey_bytes)`. +fn derive_ed25519_sui_address(pubkey_bytes: &[u8; 32]) -> Address { + SuiEd25519PublicKey::new(*pubkey_bytes).derive_address() +} + +// ============================================================ +// Helpers — PTB construction +// ============================================================ + +/// Build a `ProgrammableTransaction` containing one +/// `{package}::account::seal_approve(id, account)` MoveCall per unique id. +/// +/// We construct the PTB manually rather than going through +/// `TransactionBuilder::try_build` because that requires sender + gas, and +/// the SEAL committee only validates the inner `ProgrammableTransaction` +/// (the TS SDK uses `tx.build({ onlyTransactionKind: true })` and the key +/// server then strips the `TransactionKind` discriminator). +async fn build_seal_approve_ptb( + package_id: Address, + account_id: Address, + ids: &[Vec], + http: &reqwest::Client, + sui_rpc_url: &str, +) -> Result { + // Resolve the account object's owned-ref (version + digest). + let account_ref = get_object_ref(http, sui_rpc_url, &account_id.to_string()).await?; + + // Move id `vector`s are encoded as Pure inputs with BCS bytes. + // BCS for `vector` is ULEB128 length || raw bytes. + let mut inputs: Vec = Vec::with_capacity(ids.len() + 1); + let mut id_input_indices: Vec = Vec::with_capacity(ids.len()); + for id_bytes in ids { + // The SDK pure encoding for vector is BCS of the byte vector. + let pure_bytes = bcs::to_bytes(id_bytes) + .map_err(|e| AppError::Internal(format!("ptb id pure: {}", e)))?; + id_input_indices.push(inputs.len() as u16); + inputs.push(Input::Pure(pure_bytes)); + } + let account_input_idx = inputs.len() as u16; + let account_input = match account_ref.shared_initial_version { + // `MemWalAccount` is created via `transfer::share_object`, so the + // canonical path is `Input::Shared`. `seal_approve` takes + // `&MemWalAccount` (immutable ref) → `Mutability::Immutable`. + Some(initial_shared_version) => Input::Shared(SharedInput::new( + account_id, + initial_shared_version, + Mutability::Immutable, + )), + // Defensive fallback if owner parsing fails — preserves prior + // behavior. Logged so a regression here is visible in production + // (key servers will reject with "Object used as owned is not owned"). + None => { + tracing::warn!( + "ptb: account {} fell back to ImmutableOrOwned (owner not parsed)", + account_id + ); + Input::ImmutableOrOwned(ObjectReference::new( + account_id, + account_ref.version, + account_ref.digest, + )) + } + }; + inputs.push(account_input); + + // `Identifier::new` validates the Move identifier rules (alphanumeric + + // underscore, can't start with a digit). Both names are static so this + // never fails in practice — an Internal error here means the source + // string was tampered with at compile time. + let module = Identifier::new("account") + .map_err(|e| AppError::Internal(format!("ptb identifier 'account': {}", e)))?; + let function = Identifier::new("seal_approve") + .map_err(|e| AppError::Internal(format!("ptb identifier 'seal_approve': {}", e)))?; + + let commands: Vec = id_input_indices + .iter() + .map(|id_idx| { + Command::MoveCall(MoveCall { + package: package_id, + module: module.clone(), + function: function.clone(), + type_arguments: vec![], + arguments: vec![ + Argument::Input(*id_idx), + Argument::Input(account_input_idx), + ], + }) + }) + .collect(); + + Ok(ProgrammableTransaction { inputs, commands }) } +// ============================================================ +// Helpers — Sui JSON-RPC (read-only) +// ============================================================ + +#[derive(Debug, Clone)] +struct ObjectRef { + version: u64, + digest: Digest, + /// `Some(initial_shared_version)` if this object was shared via + /// `transfer::share_object`. PTB inputs for shared objects must use + /// `Input::Shared(SharedInput::new(id, initial_shared_version, mutability))`, + /// not `Input::ImmutableOrOwned` — otherwise the validator rejects with + /// "Object used as owned is not owned". + shared_initial_version: Option, +} + +async fn get_object_ref( + http: &reqwest::Client, + sui_rpc_url: &str, + object_id: &str, +) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "sui_getObject", + "params": [object_id, { "showOwner": true }], + }); + let resp = http + .post(sui_rpc_url) + .timeout(std::time::Duration::from_secs(15)) + .json(&body) + .send() + .await + .map_err(|e| AppError::Internal(format!("sui_getObject http: {}", e)))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| AppError::Internal(format!("sui_getObject body: {}", e)))?; + if !status.is_success() { + return Err(AppError::Internal(format!( + "sui_getObject HTTP {}: {}", + status, text + ))); + } + let v: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| AppError::Internal(format!("sui_getObject json: {}", e)))?; + if let Some(err) = v.get("error") { + return Err(AppError::Internal(format!( + "sui_getObject rpc error: {}", + err + ))); + } + let data = v + .pointer("/result/data") + .ok_or_else(|| AppError::Internal(format!("sui_getObject no data for {}", object_id)))?; + let version = data + .get("version") + .and_then(|x| x.as_str()) + .ok_or_else(|| AppError::Internal("sui_getObject missing version".into()))? + .parse::() + .map_err(|e| AppError::Internal(format!("sui_getObject version parse: {}", e)))?; + let digest_str = data + .get("digest") + .and_then(|x| x.as_str()) + .ok_or_else(|| AppError::Internal("sui_getObject missing digest".into()))?; + let digest = Digest::from_base58(digest_str) + .map_err(|e| AppError::Internal(format!("sui_getObject digest parse: {}", e)))?; + // owner: `{ "Shared": { "initial_shared_version": } }` for + // shared objects; absent / `AddressOwner` / `ObjectOwner` / `Immutable` + // otherwise. Sui fullnode RPC currently returns the version as a JSON + // number, but historically (and for huge versions) it can be a string — + // accept both. + let shared_initial_version = data + .pointer("/owner/Shared/initial_shared_version") + .and_then(|x| { + x.as_u64() + .or_else(|| x.as_str().and_then(|s| s.parse::().ok())) + }); + Ok(ObjectRef { + version, + digest, + shared_initial_version, + }) +} + +/// Parse a `0x...` Sui address from a string into an `Address`. Accepts +/// short forms (Sui CLI sometimes drops leading zeros for display). +fn parse_address(s: &str, name: &str) -> Result { + Address::from_hex(s).map_err(|e| AppError::BadRequest(format!("invalid {} '{}': {}", name, s, e))) +} + +fn sui_rpc_url_from_env() -> String { + if let Ok(v) = std::env::var("SUI_RPC_URL") { + return v; + } + let net = std::env::var("SUI_NETWORK").unwrap_or_else(|_| "mainnet".into()); + match net.as_str() { + "testnet" => "https://fullnode.testnet.sui.io:443".into(), + "devnet" => "https://fullnode.devnet.sui.io:443".into(), + _ => "https://fullnode.mainnet.sui.io:443".into(), + } +} + +// ============================================================ +// Helpers — fan out fetch_key +// ============================================================ + +async fn parallel_fetch_keys( + client: &reqwest::Client, + committee: &[std::sync::Arc], + body_json: &str, + threshold: u8, +) -> Result, AppError> { + use futures::future::join_all; + + let futs = committee.iter().map(|info| { + let client = client.clone(); + let body = body_json.to_owned(); + let info = info.clone(); + async move { + let res = fetch_key(&client, &info, &body).await; + (info.object_id, res) + } + }); + let results = join_all(futs).await; + + let mut out = Vec::with_capacity(results.len()); + let mut errors: Vec = Vec::new(); + for (server_id, res) in results { + match res { + Ok(resp) => out.push((server_id, resp)), + Err(e) => { + tracing::warn!("seal: key server {} returned error: {}", server_id, e); + errors.push(format!("{}: {}", server_id, e)); + } + } + } + if out.len() < threshold as usize { + return Err(AppError::Internal(format!( + "seal decrypt: insufficient key servers responded ({}/{}); errors: {}", + out.len(), + threshold, + errors.join(" | ") + ))); + } + Ok(out) +} + +// ============================================================ +// Tests +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::AuthInfo; + + #[test] + fn id_bytes_strips_0x_prefix() { + let b = id_bytes_from_owner("0xabcd").unwrap(); + assert_eq!(b, vec![0xab, 0xcd]); + } + + #[test] + fn id_bytes_works_without_prefix() { + let b = id_bytes_from_owner("ff00").unwrap(); + assert_eq!(b, vec![0xff, 0x00]); + } + + #[test] + fn id_bytes_rejects_empty() { + assert!(id_bytes_from_owner("").is_err()); + assert!(id_bytes_from_owner("0x").is_err()); + } + + #[test] + fn id_bytes_rejects_non_hex() { + assert!(id_bytes_from_owner("0xZZZZ").is_err()); + } + + #[test] + fn delegate_key_decode_hex_64() { + let k = "00".repeat(32); + let out = decode_delegate_private_key(&k).unwrap(); + assert_eq!(out, [0u8; 32]); + } + + #[test] + fn delegate_key_rejects_wrong_hex_length() { + let k = "00".repeat(31); + let err = decode_delegate_private_key(&k).unwrap_err(); + match err { + AppError::BadRequest(s) => assert!(s.contains("64 chars")), + other => panic!("wrong variant: {:?}", other), + } + } + + #[test] + fn delegate_key_rejects_non_hex_chars() { + let k = "z".repeat(64); + let err = decode_delegate_private_key(&k).unwrap_err(); + match err { + AppError::BadRequest(s) => assert!(s.contains("non-hex")), + other => panic!("wrong variant: {:?}", other), + } + } + + #[test] + fn delegate_key_decode_bech32_round_trip() { + // Build a `suiprivkey1...` from a known 32-byte secret and verify + // the decoded bytes match. + use bech32::{ToBase32, Variant}; + let secret = [0x42u8; 32]; + let mut data = Vec::with_capacity(33); + data.push(0x00); // Ed25519 scheme flag + data.extend_from_slice(&secret); + let bech = + bech32::encode("suiprivkey", data.to_base32(), Variant::Bech32).expect("encode"); + let decoded = decode_delegate_private_key(&bech).unwrap(); + assert_eq!(decoded, secret); + } + + #[test] + fn delegate_key_rejects_non_ed25519_scheme_flag() { + use bech32::{ToBase32, Variant}; + let mut data = Vec::with_capacity(33); + data.push(0x01); // Secp256k1 — not Ed25519 + data.extend_from_slice(&[0u8; 32]); + let bech = + bech32::encode("suiprivkey", data.to_base32(), Variant::Bech32).unwrap(); + let err = decode_delegate_private_key(&bech).unwrap_err(); + match err { + AppError::BadRequest(s) => assert!(s.contains("scheme flag")), + other => panic!("wrong variant: {:?}", other), + } + } + + #[test] + fn credential_from_auth_prefers_session() { + let auth = AuthInfo { + public_key: "aabb".into(), + owner: "0xowner".into(), + account_id: "0xaccount".into(), + delegate_key: Some("hexkey".into()), + seal_session: Some("session_blob".into()), + }; + let c = SealCredential::from_auth_or_fallback(&auth, None).unwrap(); + match c { + SealCredential::Session(s) => assert_eq!(s, "session_blob"), + other => panic!("expected Session, got {:?}", other), + } + } + + #[test] + fn credential_falls_back_to_delegate() { + let auth = AuthInfo { + public_key: "aabb".into(), + owner: "0xowner".into(), + account_id: "0xaccount".into(), + delegate_key: Some("hexkey".into()), + seal_session: None, + }; + let c = SealCredential::from_auth_or_fallback(&auth, None).unwrap(); + match c { + SealCredential::DelegateKey(s) => assert_eq!(s, "hexkey"), + other => panic!("expected DelegateKey, got {:?}", other), + } + } + + #[test] + fn credential_falls_back_to_server_key() { + let auth = AuthInfo { + public_key: "aabb".into(), + owner: "0xowner".into(), + account_id: "0xaccount".into(), + delegate_key: None, + seal_session: None, + }; + let c = SealCredential::from_auth_or_fallback(&auth, Some("server_fallback")).unwrap(); + match c { + SealCredential::DelegateKey(s) => assert_eq!(s, "server_fallback"), + other => panic!("expected DelegateKey, got {:?}", other), + } + } + + #[test] + fn credential_returns_none_when_no_creds() { + let auth = AuthInfo { + public_key: "aabb".into(), + owner: "0xowner".into(), + account_id: "0xaccount".into(), + delegate_key: None, + seal_session: None, + }; + assert!(SealCredential::from_auth_or_fallback(&auth, None).is_none()); + } + + // ── x-seal-session import tests ──────────────────────────────────── + + /// Build a deterministic SessionKey envelope for tests (matches the + /// shape `@mysten/seal`'s `SessionKey.export()` produces — verbatim). + /// The signature is intentionally a real Ed25519 signature (so the + /// `UserSignature::from_base64` parser succeeds), but it is signed by + /// a *test* keypair — Mysten key servers would reject it. That's fine + /// for unit testing: we only verify the envelope is parsed correctly + /// and translated into a `Certificate` whose fields match. + fn make_test_envelope() -> ( + String, + /* expected user addr */ Address, + /* session vk hex */ String, + /* creation_time_ms */ u64, + /* ttl_min */ u16, + ) { + use bech32::{ToBase32, Variant}; + + // Deterministic session keypair (Ed25519, 32-byte secret). + let session_secret = [0x11u8; 32]; + let mut bech_payload = Vec::with_capacity(33); + bech_payload.push(0x00); // Ed25519 scheme flag + bech_payload.extend_from_slice(&session_secret); + let session_priv_bech32 = + bech32::encode("suiprivkey", bech_payload.to_base32(), Variant::Bech32).unwrap(); + + // Recompute session_vk from the secret (same path the SDK takes). + let session_signing = ed25519_dalek::SigningKey::from_bytes(&session_secret); + let session_vk_bytes = session_signing.verifying_key().to_bytes(); + + // User-wallet keypair (used to "sign" the personal-message + // envelope — only structurally; key-server rejection is fine). + let user_secret = [0x22u8; 32]; + let user_signing = ed25519_dalek::SigningKey::from_bytes(&user_secret); + let user_pubkey_bytes = user_signing.verifying_key().to_bytes(); + let user_addr = SuiEd25519PublicKey::new(user_pubkey_bytes).derive_address(); + // Sign *some* bytes (the parser doesn't verify the signature; we + // only need it to be a syntactically-valid 0x00 || sig || pk + // GenericSignature so `UserSignature::from_base64` accepts it). + let dummy = b"unit-test personal message"; + let sig_bytes = user_signing.sign(dummy).to_bytes(); + let mut generic = Vec::with_capacity(1 + 64 + 32); + generic.push(0x00); // Ed25519 scheme flag + generic.extend_from_slice(&sig_bytes); + generic.extend_from_slice(&user_pubkey_bytes); + let pms_b64 = BASE64.encode(&generic); + let creation_time_ms: u64 = 1_700_000_000_000; + let ttl_min: u16 = 5; + let envelope = serde_json::json!({ + "address": user_addr.to_string(), + "packageId": "0x0000000000000000000000000000000000000000000000000000000000000001", + "mvrName": null, + "creationTimeMs": creation_time_ms, + "ttlMin": ttl_min, + "personalMessageSignature": pms_b64, + "sessionKey": session_priv_bech32, + }); + let envelope_b64 = BASE64.encode(envelope.to_string().as_bytes()); + ( + envelope_b64, + user_addr, + hex::encode(session_vk_bytes), + creation_time_ms, + ttl_min, + ) + } + + #[test] + fn import_session_key_parses_known_shape() { + let (b64, expected_user, expected_vk_hex, expected_ct, expected_ttl) = + make_test_envelope(); + + let resolved = resolve_session_envelope(&b64).expect("envelope must parse"); + + assert_eq!(resolved.certificate.user, expected_user, "user addr"); + assert_eq!( + hex::encode(resolved.certificate.session_vk.as_ref()), + expected_vk_hex, + "session_vk derived from sessionKey", + ); + assert_eq!( + resolved.certificate.creation_time, expected_ct, + "creation_time from creationTimeMs" + ); + assert_eq!( + resolved.certificate.ttl_min, expected_ttl, + "ttl_min from ttlMin" + ); + assert!(resolved.certificate.mvr_name.is_none(), "mvr_name nullable"); + // Sanity: signature must be Simple/Ed25519 (matches our test fixture) + assert!( + matches!( + &resolved.certificate.signature, + UserSignature::Simple(SimpleSignature::Ed25519 { .. }) + ), + "signature should round-trip as Ed25519 Simple" + ); + + // session_signing_key must derive the same public key embedded in the + // certificate — this is what's used to sign each per-request + // `signed_request` payload. + let signing_pub = resolved.session_signing_key.verifying_key().to_bytes(); + let cert_vk_bytes = resolved.certificate.session_vk.as_ref().to_vec(); + assert_eq!( + signing_pub.as_ref(), + cert_vk_bytes.as_slice(), + "session signing key matches session_vk on cert", + ); + } + + #[test] + fn import_session_key_rejects_garbage_base64() { + let err = resolve_session_envelope("!!!not base64!!!").unwrap_err(); + match err { + AppError::BadRequest(s) => assert!(s.contains("base64"), "got: {}", s), + other => panic!("wrong variant: {:?}", other), + } + } + + #[test] + fn import_session_key_rejects_non_json() { + // Valid base64, but the decoded bytes aren't JSON. + let bad = BASE64.encode(b"this is not json"); + let err = resolve_session_envelope(&bad).unwrap_err(); + match err { + AppError::BadRequest(s) => assert!(s.contains("not json") || s.contains("json")), + other => panic!("wrong variant: {:?}", other), + } + } + + #[test] + fn import_session_key_rejects_missing_fields() { + // Valid JSON object but missing `sessionKey`, `address`, etc. + let bad = BASE64.encode(b"{\"creationTimeMs\":1, \"ttlMin\":5}"); + let err = resolve_session_envelope(&bad).unwrap_err(); + match err { + AppError::BadRequest(s) => { + assert!( + s.contains("address") + || s.contains("sessionKey") + || s.contains("personalMessageSignature"), + "expected missing-field error, got: {}", + s, + ); + } + other => panic!("wrong variant: {:?}", other), + } + } + + #[test] + fn import_session_key_rejects_bad_address() { + let mut env = serde_json::json!({ + "address": "not_a_hex_address", + "packageId": "0x1", + "creationTimeMs": 1_700_000_000_000u64, + "ttlMin": 5, + "personalMessageSignature": BASE64.encode(b"\x00fake"), + "sessionKey": "00".repeat(32), + }); + // ttlMin should still parse; bad address is the failure we want to + // surface. + env["personalMessageSignature"] = serde_json::Value::String(BASE64.encode({ + let mut v = vec![0x00u8]; + v.extend_from_slice(&[0u8; 96]); + v + })); + let bad = BASE64.encode(env.to_string().as_bytes()); + let err = resolve_session_envelope(&bad).unwrap_err(); + match err { + AppError::BadRequest(s) => assert!(s.contains("address")), + other => panic!("wrong variant: {:?}", other), + } + } + + /// Live end-to-end check that `get_object_ref` correctly extracts + /// `Shared.initial_shared_version` from a real `MemWalAccount` on + /// testnet. Regression for ENG-1700 / Phase 2 bug where the parser + /// expected a JSON string but Sui returns a JSON number, causing the + /// PTB to fall back to `Input::ImmutableOrOwned` and key servers to + /// reject with HTTP 403 "Object used as owned is not owned". + /// Skip in CI (no network); run locally with `--ignored`. + #[tokio::test] + #[ignore] + async fn get_object_ref_extracts_shared_initial_version_for_real_account() { + let http = reqwest::Client::new(); + let rpc = "https://fullnode.testnet.sui.io:443"; + let memwal_account = "0x8a1121b8f95d79e68bd07efaf71689ce6fd832b369cdb1b2a943ec7beb822392"; + let r = get_object_ref(&http, rpc, memwal_account).await.expect("rpc"); + assert!( + r.shared_initial_version.is_some(), + "MemWalAccount must be Shared, but parser returned None — regression of \ + the JSON Number vs String bug. Owner is `{{Shared:{{initial_shared_version:NUMBER}}}}` \ + on testnet." + ); + } + + #[tokio::test] + async fn session_credential_dispatches_to_envelope_parser() { + // Confirm the dispatch from SealCredential::Session → resolve_session_envelope. + // We pass garbage so we get a BadRequest fast — the important thing + // is that we no longer return the "not yet supported" stub error. + let pkg = Address::ZERO; + let cred = SealCredential::Session("###".into()); + let client = reqwest::Client::new(); + let err = resolve_credential_to_session(&client, &cred, &pkg) + .await + .unwrap_err(); + match err { + AppError::BadRequest(s) => { + assert!( + !s.contains("not yet supported"), + "must not return the deprecated stub error: {}", + s, + ); + assert!( + s.contains("x-seal-session"), + "error should mention x-seal-session for caller context: {}", + s, + ); + } + other => panic!("expected BadRequest, got {:?}", other), + } + } +} diff --git a/services/server/src/seal_keyserver.rs b/services/server/src/seal_keyserver.rs new file mode 100644 index 00000000..bcb4d765 --- /dev/null +++ b/services/server/src/seal_keyserver.rs @@ -0,0 +1,431 @@ +//! SEAL key-server HTTP transport + on-chain committee resolver. +//! +//! `seal-sdk` (the Mysten Rust crate) ships the cryptographic primitives +//! (`seal_encrypt`, `signed_request`, `decrypt_seal_responses`) but does NOT +//! ship an HTTP client for the threshold key-server endpoints. The TS SDK +//! (`@mysten/seal`) wraps `/v1/fetch_key` + `/v1/public_key` for free; we +//! reimplement that wrapper here. +//! +//! Replaces the TS sidecar-side machinery for: +//! - sidecar `POST /seal/encrypt` (TS `sealClient.encrypt`) +//! - sidecar `POST /seal/decrypt` (TS `sealClient.fetchKeys` + `decrypt`) +//! - sidecar `POST /seal/decrypt-batch` (TS batch decrypt loop) +//! +//! Module responsibilities: +//! 1. **Resolve key-server committee from chain**: given a list of +//! `KeyServer` object IDs (Move type `seal::key_server::KeyServer`), +//! fetch the V1 dynamic field at version `1u64` to extract `url` + `pk` +//! (BLS12-381 G2 public key, 96 bytes). Cached per process. +//! 2. **Fan out `/v1/fetch_key` POST**: build one request body +//! (`FetchKeyRequest::to_json_string()`) and send it to all servers in +//! parallel with a 30s timeout. Collect successful `FetchKeyResponse` +//! JSON bodies. Caller checks the threshold. +//! +//! All threshold aggregation + decryption happens in `crate::seal`. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use crypto::ibe::PublicKey as IBEPublicKey; +use crypto::ObjectID; +use fastcrypto::serde_helpers::ToFromByteArray; +use once_cell::sync::Lazy; +use seal_sdk::FetchKeyResponse; +use std::str::FromStr; +use tokio::sync::Mutex; + +// Re-use a single 30s reqwest client per process. The caller can also pass in +// the ambient `&reqwest::Client` if it has one (tests do this). +const FETCH_KEY_TIMEOUT: Duration = Duration::from_secs(30); +const SUI_RPC_TIMEOUT: Duration = Duration::from_secs(15); + +/// V1 KeyServer expected `pk` length (BLS12-381 G2 compressed). +pub const IBE_PUBKEY_BYTES: usize = 96; + +// Re-export via once_cell — `std::sync::LazyLock` is 1.80+ and the workspace +// pins to an older toolchain in CI; once_cell is already a transient dep. +#[allow(dead_code)] +type CommitteeCache = Mutex>>; + +static COMMITTEE_CACHE: Lazy = Lazy::new(|| Mutex::new(HashMap::new())); + +/// One SEAL key server endpoint, identified by its on-chain object ID. +#[derive(Clone)] +pub struct KeyServerInfo { + /// On-chain key-server object ID (lowercase 0x-prefixed hex). + pub object_id: ObjectID, + /// Pretty name from the on-chain V1 KeyServer struct (used in logs only). + pub name: String, + /// Base URL (e.g. `https://seal-key-server-testnet-1.mystenlabs.com`). + pub url: String, + /// BLS12-381 G2 public key parsed from on-chain `pk` bytes. + pub public_key: IBEPublicKey, +} + +impl std::fmt::Debug for KeyServerInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KeyServerInfo") + .field("object_id", &self.object_id.to_string()) + .field("name", &self.name) + .field("url", &self.url) + // public_key debug-prints as a long hex blob — keep it short + .field("public_key", &"") + .finish() + } +} + +/// Errors from key-server resolution + transport. +#[derive(Debug, thiserror::Error)] +pub enum KeyServerError { + #[error("invalid key-server object id `{0}`: {1}")] + InvalidObjectId(String, String), + #[error("Sui RPC error fetching key server `{id}`: {message}")] + Rpc { id: String, message: String }, + #[error("key server `{id}` returned no V1 dynamic field at version=1")] + MissingV1Field { id: String }, + #[error("key server `{id}` V1 fields missing or malformed: {err}")] + MalformedFields { id: String, err: String }, + #[error("key server `{id}` pk bytes invalid (got {got}, expected 96)")] + InvalidPubkey { id: String, got: usize }, + #[error("key server `{0}` BLS12-381 G2 deserialize failed")] + BlsDeserialize(String), + #[error("key server `{0}` HTTP request failed: {1}")] + Http(String, String), + #[error("key server `{0}` HTTP {1}: {2}")] + HttpStatus(String, u16, String), + #[error("key server `{0}` returned malformed JSON body: {1}")] + MalformedResponse(String, String), +} + +// ============================================================ +// Public API +// ============================================================ + +/// Read `SEAL_KEY_SERVERS` from the environment as a comma-separated list of +/// hex object IDs. Empty entries are filtered. Empty list returns +/// `Vec::new()` — caller decides if that is fatal. +pub fn key_server_ids_from_env() -> Vec { + std::env::var("SEAL_KEY_SERVERS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() +} + +/// Read `SEAL_THRESHOLD` from env, default 2. Caller is responsible for +/// validating `threshold <= committee.len()`. +pub fn seal_threshold_from_env() -> u8 { + std::env::var("SEAL_THRESHOLD") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(2) +} + +/// Resolve a list of `KeyServer` object IDs to fully populated +/// `KeyServerInfo` structs. Hits the per-process cache first; missing +/// entries are fetched from `sui_rpc_url` via `suix_getDynamicFieldObject` +/// (V1 dynamic field at `name = u64(1)`). +/// +/// Order of the returned vec is the same as `key_server_ids`. +pub async fn resolve_committee( + http: &reqwest::Client, + sui_rpc_url: &str, + key_server_ids: &[String], +) -> Result>, KeyServerError> { + if key_server_ids.is_empty() { + return Ok(Vec::new()); + } + + // Snapshot what we already have to avoid holding the lock across awaits. + let mut out: Vec>> = Vec::with_capacity(key_server_ids.len()); + { + let cache = COMMITTEE_CACHE.lock().await; + for id in key_server_ids { + out.push(cache.get(&id.to_lowercase()).cloned()); + } + } + + // Fetch missing entries (sequentially — committees are tiny, 2-3 servers + // typically, and this is a one-shot warm-up). + for (idx, id) in key_server_ids.iter().enumerate() { + if out[idx].is_some() { + continue; + } + let info = fetch_key_server_info(http, sui_rpc_url, id).await?; + let info_arc = Arc::new(info); + { + let mut cache = COMMITTEE_CACHE.lock().await; + cache.insert(id.to_lowercase(), info_arc.clone()); + } + out[idx] = Some(info_arc); + } + + // All entries populated by now — unwrap is safe. + Ok(out.into_iter().map(|o| o.expect("committee resolve filled all slots")).collect()) +} + +/// POST `body_json` to `{url}/v1/fetch_key`, parse the JSON body as +/// `FetchKeyResponse`. 30s timeout, applies SDK request headers expected by +/// the key server (matches seal-cli). +pub async fn fetch_key( + http: &reqwest::Client, + info: &KeyServerInfo, + body_json: &str, +) -> Result { + let url = format!("{}/v1/fetch_key", info.url.trim_end_matches('/')); + let resp = http + .post(&url) + .timeout(FETCH_KEY_TIMEOUT) + .header("Content-Type", "application/json") + // SEAL key server validates Client-Sdk-Type/Version. Only "typescript" is + // currently recognized; sending Type=rust gets HTTP 400 InvalidSDKVersion. + // Until upstream adds rust support, mirror what `@mysten/seal` 1.1.1 sends. + .header("Client-Sdk-Type", "typescript") + .header("Client-Sdk-Version", "1.1.1") + .header("Request-Id", uuid::Uuid::new_v4().to_string()) + .body(body_json.to_owned()) + .send() + .await + .map_err(|e| KeyServerError::Http(info.object_id.to_string(), e.to_string()))?; + + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + KeyServerError::Http(info.object_id.to_string(), format!("read body: {}", e)) + })?; + if !status.is_success() { + return Err(KeyServerError::HttpStatus( + info.object_id.to_string(), + status.as_u16(), + body, + )); + } + serde_json::from_str::(&body).map_err(|e| { + KeyServerError::MalformedResponse(info.object_id.to_string(), e.to_string()) + }) +} + +/// Wipe the on-chain key-server cache. Used by tests to ensure isolation. +#[cfg(test)] +pub async fn _clear_cache_for_tests() { + COMMITTEE_CACHE.lock().await.clear(); +} + +// ============================================================ +// Internals +// ============================================================ + +/// Fetch the V1 KeyServer object from chain via JSON-RPC. We use +/// `suix_getDynamicFieldObject` with the U64 dynamic field name `1` (V1 +/// version per `seal::key_server::KeyServer`). +async fn fetch_key_server_info( + http: &reqwest::Client, + sui_rpc_url: &str, + object_id_str: &str, +) -> Result { + let object_id = ObjectID::from_str(object_id_str).map_err(|e| { + KeyServerError::InvalidObjectId(object_id_str.to_string(), e.to_string()) + })?; + + // suix_getDynamicFieldObject(parent_id, { type: "u64", value: "1" }) + // The Move name is `u64(1)`, encoded by JSON-RPC convention as a string. + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "suix_getDynamicFieldObject", + "params": [ + object_id_str, + { "type": "u64", "value": "1" }, + ], + }); + + let resp = http + .post(sui_rpc_url) + .timeout(SUI_RPC_TIMEOUT) + .json(&body) + .send() + .await + .map_err(|e| KeyServerError::Rpc { + id: object_id_str.to_string(), + message: e.to_string(), + })?; + + let status = resp.status(); + let text = resp.text().await.map_err(|e| KeyServerError::Rpc { + id: object_id_str.to_string(), + message: format!("read body: {}", e), + })?; + if !status.is_success() { + return Err(KeyServerError::Rpc { + id: object_id_str.to_string(), + message: format!("HTTP {}: {}", status, text), + }); + } + let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| KeyServerError::Rpc { + id: object_id_str.to_string(), + message: format!("JSON parse: {} (body={})", e, text), + })?; + + if let Some(err) = v.get("error") { + return Err(KeyServerError::Rpc { + id: object_id_str.to_string(), + message: format!("RPC error: {}", err), + }); + } + + // Path: result.data.content.fields.value.fields { url, name, pk } + let fields = v + .pointer("/result/data/content/fields") + .ok_or_else(|| KeyServerError::MissingV1Field { + id: object_id_str.to_string(), + })?; + // Some Sui RPC responses nest the V1 struct under `value.fields`; others + // expose it directly. Try the nested location first, fall back to the + // top-level fields object. + let inner = fields + .pointer("/value/fields") + .or(Some(fields)) + .ok_or_else(|| KeyServerError::MalformedFields { + id: object_id_str.to_string(), + err: "no value.fields".into(), + })?; + + let url = inner + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| KeyServerError::MalformedFields { + id: object_id_str.to_string(), + err: "missing 'url'".into(), + })? + .to_string(); + + let name = inner + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + // `pk` is serialized as a JSON array of u8 numbers when fetched from the + // Sui JSON-RPC. Tolerate both array form (list of numbers) and the rare + // hex-string fallback used by some custom RPCs. + let pk_bytes: Vec = match inner.get("pk") { + Some(serde_json::Value::Array(arr)) => { + let mut out = Vec::with_capacity(arr.len()); + for n in arr { + let byte = n.as_u64().and_then(|n| u8::try_from(n).ok()).ok_or_else(|| { + KeyServerError::MalformedFields { + id: object_id_str.to_string(), + err: format!("bad pk byte: {}", n), + } + })?; + out.push(byte); + } + out + } + Some(serde_json::Value::String(s)) => { + // Tolerate optional 0x-prefix. + let s = s.strip_prefix("0x").unwrap_or(s); + hex::decode(s).map_err(|e| KeyServerError::MalformedFields { + id: object_id_str.to_string(), + err: format!("pk hex decode: {}", e), + })? + } + _ => { + return Err(KeyServerError::MalformedFields { + id: object_id_str.to_string(), + err: "missing or invalid 'pk' field".into(), + }); + } + }; + + if pk_bytes.len() != IBE_PUBKEY_BYTES { + return Err(KeyServerError::InvalidPubkey { + id: object_id_str.to_string(), + got: pk_bytes.len(), + }); + } + let mut pk_arr = [0u8; IBE_PUBKEY_BYTES]; + pk_arr.copy_from_slice(&pk_bytes); + let public_key = IBEPublicKey::from_byte_array(&pk_arr) + .map_err(|_| KeyServerError::BlsDeserialize(object_id_str.to_string()))?; + + tracing::info!( + "seal_keyserver: resolved {} ({}) → {}", + object_id_str, + name, + url + ); + + Ok(KeyServerInfo { + object_id, + name, + url, + public_key, + }) +} + +// ============================================================ +// Tests +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Env-var tests share process state. We serialize them through a Mutex + // so `cargo test`'s default parallel run doesn't make them race with + // each other (e.g. SEAL_KEY_SERVERS being unset in one test while + // another reads it). std::sync::Mutex is sufficient — these tests are + // synchronous. + use std::sync::Mutex; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn key_server_ids_from_env_parses_csv() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + std::env::set_var("SEAL_KEY_SERVERS", "0xabc, 0xdef ,0x123"); + let ids = key_server_ids_from_env(); + std::env::remove_var("SEAL_KEY_SERVERS"); + assert_eq!(ids, vec!["0xabc", "0xdef", "0x123"]); + } + + #[test] + fn key_server_ids_from_env_handles_empty() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + std::env::remove_var("SEAL_KEY_SERVERS"); + assert!(key_server_ids_from_env().is_empty()); + std::env::set_var("SEAL_KEY_SERVERS", ""); + let after = key_server_ids_from_env(); + std::env::remove_var("SEAL_KEY_SERVERS"); + assert!(after.is_empty()); + } + + #[test] + fn seal_threshold_default_is_2() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + std::env::remove_var("SEAL_THRESHOLD"); + assert_eq!(seal_threshold_from_env(), 2); + } + + #[test] + fn seal_threshold_parses_env() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + std::env::set_var("SEAL_THRESHOLD", "3"); + assert_eq!(seal_threshold_from_env(), 3); + std::env::set_var("SEAL_THRESHOLD", "garbage"); + // Falls back to default on parse error. + assert_eq!(seal_threshold_from_env(), 2); + std::env::remove_var("SEAL_THRESHOLD"); + } + + #[tokio::test] + async fn resolve_committee_empty_returns_empty() { + let http = reqwest::Client::new(); + let out = resolve_committee(&http, "https://example.invalid", &[]).await.unwrap(); + assert!(out.is_empty()); + } +} diff --git a/services/server/src/types.rs b/services/server/src/types.rs index b91cc485..3fc97ca4 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -20,6 +20,8 @@ pub struct AppState { pub redis: redis::aio::MultiplexedConnection, /// In-memory token bucket fallback for when Redis is unavailable pub fallback_rate_limit: tokio::sync::Mutex, + /// Enoki client for sponsored Sui transactions. Configured (or not) at boot. + pub enoki: crate::enoki::EnokiClient, } // ============================================================ @@ -91,10 +93,21 @@ pub struct Config { pub sui_private_keys: Vec, pub package_id: String, pub registry_id: String, - /// URL of the SEAL/Walrus TS sidecar HTTP server - pub sidecar_url: String, - /// Shared secret for authenticating Rust→sidecar calls (X-Sidecar-Secret header) - pub sidecar_secret: Option, + /// Enoki API key for sponsored Sui transactions. None → /sponsor returns 503. + pub enoki_api_key: Option, + /// Enoki network ("mainnet" / "testnet" / "devnet"). Defaults to `sui_network`. + pub enoki_network: String, + /// ENG-1700: when Enoki sponsorship fails for the metadata+transfer PTB, + /// fall back to direct signing with the server's key pool. Default true, + /// matching legacy sidecar `ENOKI_FALLBACK_TO_DIRECT_SIGN`. + /// + /// Read via env per call inside `walrus::upload_blob` (matching the + /// existing per-call env-read pattern for related Walrus / Enoki vars), + /// so this Config field is currently informational — kept on the struct + /// for completeness, ops dashboards, and future refactors that thread + /// `&AppState` into the Walrus pipeline. + #[allow(dead_code)] + pub enoki_fallback_to_direct_sign: bool, /// Rate limiting configuration pub rate_limit: RateLimitConfig, /// Sponsor-specific rate limiting and concurrency config @@ -149,9 +162,12 @@ impl Config { .expect("MEMWAL_PACKAGE_ID must be set"), registry_id: std::env::var("MEMWAL_REGISTRY_ID") .expect("MEMWAL_REGISTRY_ID must be set"), - sidecar_url: std::env::var("SIDECAR_URL") - .unwrap_or_else(|_| "http://localhost:9000".to_string()), - sidecar_secret: std::env::var("SIDECAR_AUTH_TOKEN").ok(), + enoki_api_key: std::env::var("ENOKI_API_KEY").ok().filter(|s| !s.is_empty()), + enoki_network: std::env::var("ENOKI_NETWORK") + .unwrap_or_else(|_| network.clone()), + enoki_fallback_to_direct_sign: parse_enoki_fallback_to_direct_sign( + std::env::var("ENOKI_FALLBACK_TO_DIRECT_SIGN").ok(), + ), rate_limit: RateLimitConfig::from_env(), sponsor_rate_limit: SponsorRateLimitConfig::from_env(), allowed_origins: std::env::var("ALLOWED_ORIGINS") @@ -160,6 +176,18 @@ impl Config { } } +/// Parse `ENOKI_FALLBACK_TO_DIRECT_SIGN` env var to a bool. +/// +/// Default is `true`. Only `"0"` / `"false"` / `"no"` (case/whitespace +/// insensitive) yield `false`; any other value (including unset) yields +/// `true`. Preserves prior behavior so existing deploys keep working. +pub(crate) fn parse_enoki_fallback_to_direct_sign(raw: Option) -> bool { + match raw.map(|s| s.trim().to_lowercase()) { + Some(s) if matches!(s.as_str(), "0" | "false" | "no") => false, + _ => true, + } +} + // ============================================================ // Sponsor Rate Limit Config // ============================================================ @@ -297,7 +325,7 @@ pub struct AnalyzeResponse { /// POST /api/remember/manual /// Client sends SEAL-encrypted data (base64) + pre-computed embedding vector. -/// Server uploads to Walrus via sidecar, then stores the vector ↔ blobId mapping. +/// Server uploads to Walrus, then stores the vector ↔ blobId mapping. #[derive(Debug, Deserialize)] pub struct RememberManualRequest { pub encrypted_data: String, // base64-encoded SEAL-encrypted bytes @@ -398,15 +426,24 @@ pub struct ConfigResponse { // Sponsor Types // ============================================================ -/// POST /sponsor — validated request body forwarded to sidecar +/// POST /sponsor — validated request body forwarded to Enoki. +/// +/// `allowed_addresses` is forwarded to Enoki (`allowedAddresses`) when +/// present so multi-tenant clients can scope sponsorship to a recipient +/// wallet that isn't pre-allow-listed at the API-key level. The Enoki +/// API treats an absent `allowedAddresses` field as "no extra +/// restrictions"; `routes::sponsor` only forwards the field when the +/// array is non-empty. #[derive(Debug, Deserialize)] pub struct SponsorRequest { pub sender: String, #[serde(rename = "transactionBlockKindBytes")] pub transaction_block_kind_bytes: String, + #[serde(rename = "allowedAddresses", default)] + pub allowed_addresses: Option>, } -/// POST /sponsor/execute — validated request body forwarded to sidecar. +/// POST /sponsor/execute — validated request body forwarded to Enoki. /// `sender` is optional — when present it is validated and counted against /// the per-sender rate limit bucket (same axis as POST /sponsor). #[derive(Debug, Deserialize)] @@ -525,16 +562,6 @@ impl axum::response::IntoResponse for AppError { } } -// ============================================================ -// Sidecar Types (shared by seal.rs + walrus.rs) -// ============================================================ - -/// Error response from the TS sidecar HTTP server -#[derive(Debug, Deserialize)] -pub struct SidecarError { - pub error: String, -} - // ============================================================ // Unit Tests // ============================================================ @@ -737,4 +764,36 @@ mod tests { assert!(AppError::RateLimited("x".into()).to_string().contains("Rate Limited")); assert!(AppError::QuotaExceeded("x".into()).to_string().contains("Quota Exceeded")); } + + // ── parse_enoki_fallback_to_direct_sign (ENG-1700) ────────────────── + + #[test] + fn parse_enoki_fallback_default_true_when_unset() { + // Unset env → fallback enabled (legacy sidecar default). + assert!(parse_enoki_fallback_to_direct_sign(None)); + } + + #[test] + fn parse_enoki_fallback_recognizes_disable_aliases() { + // Only "0" / "false" / "no" (case + whitespace insensitive) disable it. + for raw in ["0", "false", "FALSE", "False", "no", "NO", " false ", "0\n"] { + assert!( + !parse_enoki_fallback_to_direct_sign(Some(raw.to_string())), + "expected {:?} to parse as disable", + raw, + ); + } + } + + #[test] + fn parse_enoki_fallback_other_values_keep_default_true() { + // Anything not matching the disable aliases stays true (matches sidecar). + for raw in ["true", "1", "yes", "garbage", ""] { + assert!( + parse_enoki_fallback_to_direct_sign(Some(raw.to_string())), + "expected {:?} to parse as enable", + raw, + ); + } + } } diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index c05811f3..a22edbb1 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -1,5 +1,26 @@ -use crate::types::{AppError, SidecarError}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +//! Walrus upload + on-chain query layer (ENG-1700). +//! +//! In-process replacement for the deleted Node sidecar: +//! - Upload: PUT to a public Walrus publisher (`walrus_publisher.rs`) +//! followed by a metadata-set + transfer PTB (`walrus_onchain.rs`). +//! - Query: native Sui JSON-RPC `suix_getOwnedObjects` + dynamic-field +//! reads, with the same numeric→base64url `blob_id` conversion the +//! prior implementation used. +//! +//! `download_blob` is unchanged — it already used `walrus_rs` natively. + +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL, Engine}; + +use crate::enoki::EnokiClient; +use crate::types::{parse_enoki_fallback_to_direct_sign, AppError, KeyPool}; +use crate::walrus_onchain::{self, ServerSigner}; +use crate::walrus_publisher::{self, PublisherError}; + +// ============================================================ +// Public types (unchanged shape) +// ============================================================ /// Result of a Walrus blob upload pub struct UploadResult { @@ -27,160 +48,454 @@ pub struct OnChainBlob { pub package_id: String, } -/// Response from sidecar query-blobs endpoint -#[derive(Debug, serde::Deserialize)] -struct QueryBlobsResponse { - blobs: Vec, - total: usize, -} - -/// Request/response types for sidecar HTTP API -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct WalrusUploadRequest { - data: String, - key_index: usize, - owner: String, - namespace: String, - package_id: String, - epochs: u64, - #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")] - agent_id: Option, -} +// ============================================================ +// upload_blob — native publisher + on-chain PTB +// ============================================================ -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct WalrusUploadResponse { - blob_id: String, - object_id: Option, -} - -/// Upload an encrypted blob to Walrus via the HTTP sidecar. -/// -/// Calls the long-lived sidecar server at `POST /walrus/upload` which uses -/// `@mysten/walrus` SDK with the multi-step writeBlobFlow. +/// Upload an encrypted blob to Walrus and set on-chain metadata. /// -/// The server wallet pays for gas + storage. After certify, the blob object -/// is transferred to `owner_address`. Namespace + owner are stored as -/// on-chain metadata attributes for discoverability. +/// Pipeline: +/// 1. Decode the chosen pool key into the server signer (Ed25519 + address). +/// 2. PUT the blob to the Walrus publisher with `send_object_to=` +/// so the freshly minted `Blob` is owned by us (we need to mutate it). +/// 3. Run the metadata-set + transfer PTB: +/// - `WALRUS_PKG::blob::insert_or_update_metadata_pair` ×4 (memwal_*) +/// - `transfer_objects([blob], owner_address)` +/// 4. Return `UploadResult { blob_id, object_id }`. #[allow(clippy::too_many_arguments)] pub async fn upload_blob( client: &reqwest::Client, - sidecar_url: &str, - sidecar_secret: Option<&str>, data: &[u8], epochs: u64, owner_address: &str, key_index: usize, namespace: &str, - package_id: &str, + package_id: &str, // MemWal package ID (for memwal_package_id metadata value) agent_id: Option<&str>, ) -> Result { - let url = format!("{}/walrus/upload", sidecar_url); - let data_b64 = BASE64.encode(data); - - let mut req = client - .post(&url) - .json(&WalrusUploadRequest { - data: data_b64, - key_index, - owner: owner_address.to_string(), - namespace: namespace.to_string(), - package_id: package_id.to_string(), - epochs, - agent_id: agent_id.map(|s| s.to_string()), - }); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req - .send() - .await - .map_err(|e| { - AppError::Internal(format!("Sidecar walrus/upload request failed: {}. Is the sidecar running?", e)) - })?; + // LOW-17 parity: cap epochs at 5 to prevent accidental large storage spend. + let capped_epochs = epochs.min(5); - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - if let Ok(err) = serde_json::from_str::(&body) { - return Err(AppError::Internal(format!("walrus upload failed: {}", err.error))); + // Resolve sui rpc + walrus publisher + walrus package id from env. We + // can't reach into AppState from here without a wider refactor, so + // pull from env directly (matches the existing pattern in routes). + let sui_rpc_url = std::env::var("SUI_RPC_URL").unwrap_or_else(|_| { + match std::env::var("SUI_NETWORK").as_deref() { + Ok("testnet") => "https://fullnode.testnet.sui.io:443".to_string(), + Ok("devnet") => "https://fullnode.devnet.sui.io:443".to_string(), + _ => "https://fullnode.mainnet.sui.io:443".to_string(), } - return Err(AppError::Internal(format!("walrus upload failed: {}", body))); - } + }); + let publisher_url = std::env::var("WALRUS_PUBLISHER_URL") + .unwrap_or_else(|_| "https://publisher.walrus-mainnet.walrus.space".to_string()); + let network = std::env::var("SUI_NETWORK").unwrap_or_else(|_| "mainnet".to_string()); + let walrus_pkg = walrus_onchain::resolve_walrus_package_id(&network); - let result: WalrusUploadResponse = resp.json().await.map_err(|e| { - AppError::Internal(format!("Failed to parse walrus/upload response: {}", e)) - })?; + // Step 1: load signer from KeyPool via env (KeyPool itself is on AppState + // and not available here without changing callers — match the existing + // pattern used by routes which already passed `key_index`). + let pool_keys = load_pool_keys_from_env(); + let priv_key = pool_keys + .get(key_index) + .ok_or_else(|| AppError::Internal(format!("KeyPool index {} out of bounds", key_index)))?; + let signer = ServerSigner::from_suiprivkey(priv_key) + .map_err(|e| AppError::Internal(format!("decode server private key: {}", e)))?; + let server_address = signer.address_hex(); + + // Step 2: publish to Walrus + let published = walrus_publisher::upload_blob_via_publisher( + client, + &publisher_url, + data, + capped_epochs, + &server_address, + ) + .await + .map_err(map_publisher_error)?; + + let blob_object_id = match &published.object_id { + Some(id) => id.clone(), + None => { + // alreadyCertified branch — already errored out above, but be defensive. + return Err(AppError::Internal( + "Walrus publisher did not return a Blob object id".into(), + )); + } + }; + + // Step 3: metadata-set + transfer PTB. + // + // When `ENOKI_API_KEY` is set we try Enoki sponsorship first, falling + // back to direct-sign on error if `ENOKI_FALLBACK_TO_DIRECT_SIGN=true` + // (default true). When the key is unset, the EnokiClient short-circuits + // and we go straight to direct sign. + let enoki_api_key = std::env::var("ENOKI_API_KEY").ok().filter(|s| !s.is_empty()); + let enoki_network = std::env::var("ENOKI_NETWORK").unwrap_or_else(|_| { + std::env::var("SUI_NETWORK").unwrap_or_else(|_| "mainnet".into()) + }); + let enoki = EnokiClient::new(enoki_api_key, enoki_network); + let enoki_fallback = parse_enoki_fallback_to_direct_sign( + std::env::var("ENOKI_FALLBACK_TO_DIRECT_SIGN").ok(), + ); + + walrus_onchain::set_metadata_and_transfer( + client, + &sui_rpc_url, + &signer, + &blob_object_id, + &walrus_pkg, + namespace, + owner_address, // target_owner: the user + owner_address, // memwal_owner metadata value + package_id, // memwal_package_id metadata value + agent_id, + &enoki, + enoki_fallback, + ) + .await + .map_err(|e| AppError::Internal(format!("Walrus metadata+transfer failed: {}", e)))?; tracing::info!( - "walrus upload via sidecar ok: blob_id={}, object_id={:?}, owner={}, ns={}", - result.blob_id, - result.object_id, + "walrus upload+chain ok: blob_id={}, object_id={}, owner={}, ns={}", + published.blob_id, + blob_object_id, owner_address, - namespace + namespace, ); Ok(UploadResult { - blob_id: result.blob_id, - object_id: result.object_id, + blob_id: published.blob_id, + object_id: Some(blob_object_id), }) } -/// Query user's Walrus Blob objects from the Sui chain via sidecar. +fn load_pool_keys_from_env() -> Vec { + if let Ok(s) = std::env::var("SERVER_SUI_PRIVATE_KEYS") { + let v: Vec = s + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(); + if !v.is_empty() { + return v; + } + } + if let Ok(s) = std::env::var("SERVER_SUI_PRIVATE_KEY") { + if !s.trim().is_empty() { + return vec![s]; + } + } + Vec::new() +} + +fn map_publisher_error(e: PublisherError) -> AppError { + match e { + PublisherError::AlreadyCertifiedNoObject => AppError::Internal( + "Walrus already-certified branch: cannot transfer (no Blob object minted). \ + This should be unreachable for SEAL-encrypted blobs." + .into(), + ), + other => AppError::Internal(format!("Walrus publisher: {}", other)), + } +} + +// ============================================================ +// query_blobs_by_owner — native Sui RPC + dynamic-field reads +// ============================================================ + +/// Query user's Walrus Blob objects from the Sui chain. /// -/// This enables restore-from-zero: even if the local DB is empty, -/// we can discover all blob_ids by querying the user's on-chain objects -/// and reading the `memwal_namespace` metadata attribute. +/// Uses `suix_getOwnedObjects` with a `StructType` filter for +/// `WALRUS_PACKAGE_ID::blob::Blob`, paginates, then fetches the on-chain +/// metadata VecMap via `suix_getDynamicFieldObject` (key: b"metadata") for +/// each Blob. Filters by namespace / package_id client-side. pub async fn query_blobs_by_owner( client: &reqwest::Client, - sidecar_url: &str, - sidecar_secret: Option<&str>, owner_address: &str, namespace: Option<&str>, package_id: Option<&str>, ) -> Result, AppError> { - let url = format!("{}/walrus/query-blobs", sidecar_url); + let sui_rpc_url = std::env::var("SUI_RPC_URL").unwrap_or_else(|_| { + match std::env::var("SUI_NETWORK").as_deref() { + Ok("testnet") => "https://fullnode.testnet.sui.io:443".to_string(), + Ok("devnet") => "https://fullnode.devnet.sui.io:443".to_string(), + _ => "https://fullnode.mainnet.sui.io:443".to_string(), + } + }); + let network = std::env::var("SUI_NETWORK").unwrap_or_else(|_| "mainnet".to_string()); + let walrus_pkg = walrus_onchain::resolve_walrus_package_id(&network); + let walrus_blob_type = format!("{}::blob::Blob", walrus_pkg); - let mut body = serde_json::json!({ "owner": owner_address }); - if let Some(ns) = namespace { - body["namespace"] = serde_json::json!(ns); - } - if let Some(pkg) = package_id { - body["packageId"] = serde_json::json!(pkg); + // ── 1. Paginate getOwnedObjects ───────────────────────────────────── + #[derive(Debug, Clone)] + struct RawBlob { + object_id: String, + raw_blob_id: Option, // u256 as decimal string } - let mut req = client - .post(&url) - .json(&body); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req - .send() - .await - .map_err(|e| { - AppError::Internal(format!("Sidecar walrus/query-blobs failed: {}", e)) - })?; + let mut raw_objs: Vec = Vec::new(); + let mut cursor: Option = None; + loop { + let params = serde_json::json!([ + owner_address, + { + "filter": { "StructType": walrus_blob_type }, + "options": { "showContent": true } + }, + cursor, + 50 + ]); + let v = sui_rpc(client, &sui_rpc_url, "suix_getOwnedObjects", params).await?; + let data = v + .pointer("/result/data") + .and_then(|x| x.as_array()) + .cloned() + .unwrap_or_default(); + for obj in data { + // Path: data.content.dataType=="moveObject" && data.content.fields.blob_id + let content = match obj.pointer("/data/content") { + Some(c) => c, + None => continue, + }; + if content.get("dataType").and_then(|x| x.as_str()) != Some("moveObject") { + continue; + } + let object_id = match obj.pointer("/data/objectId").and_then(|x| x.as_str()) { + Some(s) => s.to_string(), + None => continue, + }; + let raw_blob_id = content + .pointer("/fields/blob_id") + .or_else(|| content.pointer("/fields/blobId")) + .and_then(|x| x.as_str().map(String::from).or_else(|| x.as_u64().map(|n| n.to_string()))); + raw_objs.push(RawBlob { + object_id, + raw_blob_id, + }); + } - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(AppError::Internal(format!("walrus query-blobs failed: {}", body))); + let has_next = v + .pointer("/result/hasNextPage") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let next_cursor = v + .pointer("/result/nextCursor") + .and_then(|x| x.as_str()) + .map(String::from); + if !has_next || next_cursor.is_none() { + break; + } + cursor = next_cursor; } - let result: QueryBlobsResponse = resp.json().await.map_err(|e| { - AppError::Internal(format!("Failed to parse query-blobs response: {}", e)) - })?; + tracing::info!( + "walrus query: found {} raw blob objects for owner={}", + raw_objs.len(), + owner_address, + ); + + // ── 2. Fetch metadata for each Blob (bounded concurrency) ────────── + // The TS sidecar uses concurrency=5; we mirror that to avoid 429s. + use futures::stream::{self, StreamExt}; + let metas: Vec<(RawBlob, BlobMeta)> = stream::iter(raw_objs.into_iter()) + .map(|obj| { + let url = sui_rpc_url.clone(); + let client = client.clone(); + async move { + let meta = fetch_blob_metadata(&client, &url, &obj.object_id).await; + (obj, meta.unwrap_or_default()) + } + }) + .buffer_unordered(5) + .collect() + .await; + + // ── 3. Filter + convert blob IDs ─────────────────────────────────── + let mut out: Vec = Vec::new(); + for (obj, meta) in metas { + if let Some(ns) = namespace { + if meta.namespace != ns { + continue; + } + } + if let Some(pkg) = package_id { + if meta.package_id != pkg { + continue; + } + } + let raw = match &obj.raw_blob_id { + Some(s) => s.clone(), + None => continue, + }; + let blob_id = u256_decimal_to_base64url(&raw).unwrap_or(raw); + out.push(OnChainBlob { + blob_id, + object_id: obj.object_id, + namespace: if meta.namespace.is_empty() { + "default".to_string() + } else { + meta.namespace + }, + package_id: meta.package_id, + }); + } tracing::info!( - "walrus query-blobs ok: {} blobs for owner={}, ns={:?}", - result.total, owner_address, namespace + "walrus query: returning {} blobs for owner={} ns={:?}", + out.len(), + owner_address, + namespace, ); + Ok(out) +} - Ok(result.blobs) +#[derive(Debug, Default)] +struct BlobMeta { + namespace: String, + #[allow(dead_code)] + owner: String, + package_id: String, + #[allow(dead_code)] + agent_id: String, } +/// Read the on-chain metadata VecMap for a single Blob via +/// `suix_getDynamicFieldObject` with key b"metadata". +async fn fetch_blob_metadata( + client: &reqwest::Client, + rpc_url: &str, + object_id: &str, +) -> Result { + // Field name: { type: "vector", value: bytes("metadata") } + let metadata_bytes: Vec = "metadata".bytes().map(|b| b as u64).collect(); + let params = serde_json::json!([ + object_id, + { + "type": "vector", + "value": metadata_bytes, + } + ]); + let v = sui_rpc(client, rpc_url, "suix_getDynamicFieldObject", params).await?; + + // Navigate to the VecMap entries: + // result.data.content.fields.value.fields.metadata.fields.contents[] + let contents = v + .pointer("/result/data/content/fields/value/fields/metadata/fields/contents") + .and_then(|x| x.as_array()) + .cloned() + .unwrap_or_default(); + + let mut meta = BlobMeta::default(); + for entry in contents { + let key = entry.pointer("/fields/key").and_then(|x| x.as_str()).unwrap_or(""); + let value = entry + .pointer("/fields/value") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + match key { + "memwal_namespace" => meta.namespace = value, + "memwal_owner" => meta.owner = value, + "memwal_package_id" => meta.package_id = value, + "memwal_agent_id" => meta.agent_id = value, + _ => {} + } + } + Ok(meta) +} + +/// Convert a U256 decimal string (as Walrus stores `blob_id` on-chain) into +/// a base64url-no-pad string: +/// +/// BigInt(decimal) → 64-char hex (zero-padded BE) → bytes BE → reverse to LE +/// → base64url-no-pad. +fn u256_decimal_to_base64url(decimal: &str) -> Option { + if decimal.is_empty() || !decimal.chars().all(|c| c.is_ascii_digit()) { + return None; + } + // Use `num` semantics manually since we don't depend on num-bigint. + // Convert via BigUint-via-byte-arithmetic: parse base-10 into a 32-byte + // big-endian buffer. + let mut be = [0u8; 32]; + let mut started = false; + let mut digit_count = 0usize; + for ch in decimal.chars() { + let d = ch.to_digit(10)? as u8; + // be := be * 10 + d (with overflow → None if >32 bytes) + let mut carry: u16 = d as u16; + for byte in be.iter_mut().rev() { + let prod = (*byte as u16) * 10 + carry; + *byte = (prod & 0xff) as u8; + carry = prod >> 8; + } + if carry != 0 { + return None; // overflow > 256 bits + } + digit_count += 1; + if d != 0 { + started = true; + } + // Keep iterating; mirror TS behaviour which doesn't truncate leading zeros. + let _ = started; + } + if digit_count == 0 { + return None; + } + // Reverse to little-endian. + let mut le = be; + le.reverse(); + Some(BASE64_URL.encode(le)) +} + +async fn sui_rpc( + client: &reqwest::Client, + url: &str, + method: &str, + params: serde_json::Value, +) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + let resp = client + .post(url) + .timeout(Duration::from_secs(30)) + .json(&body) + .send() + .await + .map_err(|e| AppError::Internal(format!("Sui RPC {} failed: {}", method, e)))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| AppError::Internal(format!("Sui RPC {} read body: {}", method, e)))?; + if !status.is_success() { + return Err(AppError::Internal(format!( + "Sui RPC {} HTTP {}: {}", + method, status, text + ))); + } + let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| { + AppError::Internal(format!("Sui RPC {} JSON parse: {} (body={})", method, e, text)) + })?; + if v.get("error").is_some() { + return Err(AppError::Internal(format!( + "Sui RPC {} returned error: {}", + method, + v.get("error").unwrap() + ))); + } + Ok(v) +} + +// ============================================================ +// download_blob — unchanged (already native via walrus_rs) +// ============================================================ + /// Download a blob from Walrus via the walrus_rs SDK (Aggregator HTTP API). -/// Note: this is already native Rust — no sidecar needed. /// /// Returns `AppError::BlobNotFound` when the blob has expired or doesn't exist /// (HTTP 404 from the aggregator). Callers can use this to trigger DB cleanup. @@ -188,12 +503,13 @@ pub async fn download_blob( walrus_client: &walrus_rs::WalrusClient, blob_id: &str, ) -> Result, AppError> { - // Timeout to avoid hanging on broken/slow blobs (Walrus 500s can take 60s+) let download_fut = walrus_client.read_blob_by_id(blob_id); let bytes = match tokio::time::timeout( std::time::Duration::from_secs(15), download_fut, - ).await { + ) + .await + { Ok(Ok(data)) => data, Ok(Err(e)) => { let err_str = e.to_string(); @@ -201,13 +517,22 @@ pub async fn download_blob( || err_str.to_lowercase().contains("not found") || err_str.to_lowercase().contains("blob not found"); if is_not_found { - return Err(AppError::BlobNotFound(format!("Blob {} expired or not found: {}", blob_id, err_str))); + return Err(AppError::BlobNotFound(format!( + "Blob {} expired or not found: {}", + blob_id, err_str + ))); } else { - return Err(AppError::Internal(format!("Walrus download failed: {}", err_str))); + return Err(AppError::Internal(format!( + "Walrus download failed: {}", + err_str + ))); } } Err(_) => { - return Err(AppError::Internal(format!("Walrus download timed out after 10s for blob {}", blob_id))); + return Err(AppError::Internal(format!( + "Walrus download timed out after 10s for blob {}", + blob_id + ))); } }; @@ -219,3 +544,55 @@ pub async fn download_blob( Ok(bytes) } +// Keep `KeyPool` import alive even though we currently re-derive keys via env +// inside `upload_blob`. Removing the import would break a future refactor that +// threads `&AppState` here. +#[allow(dead_code)] +fn _keep_key_pool_in_use(_p: &KeyPool) {} + +// ============================================================ +// Tests +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u256_to_base64url_roundtrip_zero() { + // 0 → 32 bytes of zero → base64url of 32 zero bytes. + let s = u256_decimal_to_base64url("0").unwrap(); + // 32 bytes → 43 base64url chars (no padding). + assert_eq!(s.len(), 43); + assert!(s.chars().all(|c| c == 'A')); + } + + #[test] + fn u256_to_base64url_one() { + // 1 (BE: 31 zero bytes + 0x01) → reversed (LE) starts with 0x01. + let s = u256_decimal_to_base64url("1").unwrap(); + // First byte is 0x01 — first base64url char encodes 6 bits of 0x01. + // 0x01 in 6-bit group => 0b000000_010000xx, i.e. char = 'A' for first 6 bits = 0. + // Easier check: decode and verify the bytes. + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(s) + .unwrap(); + assert_eq!(bytes.len(), 32); + assert_eq!(bytes[0], 0x01); + assert!(bytes[1..].iter().all(|&b| b == 0)); + } + + #[test] + fn u256_to_base64url_overflow_returns_none() { + // Too large: 2^256 = 115792089237316195423570985008687907853269984665640564039457584007913129639936 + let too_big = "115792089237316195423570985008687907853269984665640564039457584007913129639936"; + assert!(u256_decimal_to_base64url(too_big).is_none()); + } + + #[test] + fn u256_to_base64url_rejects_non_digits() { + assert!(u256_decimal_to_base64url("0xabc").is_none()); + assert!(u256_decimal_to_base64url("123abc").is_none()); + assert!(u256_decimal_to_base64url("").is_none()); + } +} diff --git a/services/server/src/walrus_onchain.rs b/services/server/src/walrus_onchain.rs new file mode 100644 index 00000000..1f32128c --- /dev/null +++ b/services/server/src/walrus_onchain.rs @@ -0,0 +1,774 @@ +//! Walrus on-chain metadata + transfer PTB (ENG-1700 / Phase 3). +//! +//! Replaces the TS sidecar `metaTx` block (sidecar-server.ts L685-743) which: +//! 1. Calls `WALRUS_PACKAGE_ID::blob::insert_or_update_metadata_pair` four +//! times to set `memwal_namespace`, `memwal_owner`, `memwal_package_id`, +//! `memwal_agent_id` on the freshly minted Walrus `Blob` object. +//! 2. Calls `transfer_objects([blob], owner_address)` to hand it to the user. +//! +//! Implemented natively in Rust using: +//! - `bech32` to decode `suiprivkey1...` into the Ed25519 secret bytes +//! - `sui-crypto::ed25519::Ed25519PrivateKey` as a `Signer` +//! - `sui-transaction-builder::TransactionBuilder` for PTB construction +//! - `sui-sdk-types::{Address, Digest, Identifier, Transaction, UserSignature}` +//! - `reqwest` for `sui_getCoins`, `sui_getObject`, and +//! `sui_executeTransactionBlock` JSON-RPC. +//! +//! Gas-payment policy: parity with the legacy sidecar (sidecar-server.ts +//! `executeWithEnokiSponsor`, L210-261). When `ENOKI_API_KEY` is configured +//! we try Enoki-sponsored execution first (no gas spend on the server pool), +//! and fall back to direct-sign with the server's own gas coin if the sponsor +//! call errors and `ENOKI_FALLBACK_TO_DIRECT_SIGN` is true (the default). +//! When Enoki is unconfigured the sponsor call is short-circuited and we go +//! straight to direct-sign. The `/sponsor` proxy in `routes.rs` is a separate +//! code path used by the FE for client-built PTBs. +//! +//! Walrus package id (mainnet): `0xfdc88...` (env-overridable via +//! `WALRUS_PACKAGE_ID`). + +use std::time::Duration; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use bech32::FromBase32; +use sui_crypto::ed25519::Ed25519PrivateKey; +use sui_crypto::Signer; +use sui_sdk_types::{Address, Digest, Identifier, UserSignature}; +use sui_transaction_builder::{Function, ObjectInput, TransactionBuilder}; + +// ============================================================ +// Constants +// ============================================================ + +/// Walrus mainnet package ID (used when env var `WALRUS_PACKAGE_ID` is unset). +pub const WALRUS_PACKAGE_ID_MAINNET: &str = + "0xfdc88f7d7cf30afab2f82e8380d11ee8f70efb90e863d1de8616fae1bb09ea77"; +/// Walrus testnet package ID (used when env var `WALRUS_PACKAGE_ID` is unset and +/// `SUI_NETWORK=testnet`). +pub const WALRUS_PACKAGE_ID_TESTNET: &str = + "0xd84704c17fc870b8764832c535aa6b11f21a95cd6f5bb38a9b07d2cf42220c66"; + +/// Default gas budget for the metadata + transfer PTB (mist). +/// 50 MIST = 0.05 SUI — comfortably above typical 4-5M observed cost, +/// and consistent with TS sidecar defaults. +const DEFAULT_GAS_BUDGET: u64 = 50_000_000; +/// Default reference gas price (mist per gas unit). +/// Sui mainnet is currently 1000; we hard-code rather than hit +/// `suix_getReferenceGasPrice` per call. Override via env if needed. +const DEFAULT_GAS_PRICE: u64 = 1000; + +const SUI_RPC_TIMEOUT_SECS: u64 = 30; + +// ============================================================ +// Errors +// ============================================================ + +#[derive(Debug, thiserror::Error)] +pub enum OnchainError { + #[error("invalid Sui private key: {0}")] + PrivateKey(String), + #[error("invalid address: {0}")] + Address(String), + #[error("invalid object id: {0}")] + ObjectId(String), + #[error("Sui RPC error: {0}")] + Rpc(String), + #[error("RPC returned no gas coin for address {0}")] + NoGasCoin(String), + #[error("RPC returned no object for {0}")] + ObjectNotFound(String), + #[error("PTB build error: {0}")] + Build(String), + #[error("BCS encode error: {0}")] + Bcs(String), + #[error("signing error: {0}")] + Sign(String), + #[error("transaction execution failed: {0}")] + Execute(String), + /// Enoki sponsorship failed and `ENOKI_FALLBACK_TO_DIRECT_SIGN=false` + /// (caller opted out of direct-sign fallback). The wrapped string is + /// the underlying Enoki error display. + #[error("Enoki sponsor failed (fallback disabled): {0}")] + EnokiSponsor(String), +} + +// ============================================================ +// Public API +// ============================================================ + +/// A decoded server signer (Ed25519 keypair + Sui address). +/// +/// `Debug` is implemented manually so accidental `{:?}` never leaks the +/// private key into logs (the inner `Ed25519PrivateKey` already redacts, +/// but we redact at the wrapper level too as defense-in-depth). +pub struct ServerSigner { + pub private_key: Ed25519PrivateKey, + pub address: Address, +} + +impl std::fmt::Debug for ServerSigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServerSigner") + .field("private_key", &"") + .field("address", &self.address) + .finish() + } +} + +impl ServerSigner { + /// Decode a Sui bech32 `suiprivkey1...` string into an Ed25519 keypair + /// + the derived Sui address. Returns `OnchainError::PrivateKey` on + /// malformed input or non-Ed25519 schemes. + /// + /// Format: bech32(HRP="suiprivkey", data = [scheme_flag(1) || privkey(32)]). + /// Scheme flag 0x00 = Ed25519. See + /// `node_modules/@mysten/sui/src/cryptography/keypair.ts:125`. + pub fn from_suiprivkey(s: &str) -> Result { + let (hrp, data, _variant) = bech32::decode(s).map_err(|e| { + OnchainError::PrivateKey(format!("bech32 decode failed: {}", e)) + })?; + if hrp != "suiprivkey" { + return Err(OnchainError::PrivateKey(format!( + "unexpected bech32 HRP: {}", + hrp + ))); + } + let bytes = Vec::::from_base32(&data).map_err(|e| { + OnchainError::PrivateKey(format!("base32→bytes failed: {}", e)) + })?; + if bytes.len() != 33 { + return Err(OnchainError::PrivateKey(format!( + "expected 33-byte payload (flag||sk), got {}", + bytes.len() + ))); + } + let scheme_flag = bytes[0]; + if scheme_flag != 0x00 { + return Err(OnchainError::PrivateKey(format!( + "only Ed25519 (flag=0x00) is supported, got 0x{:02x}", + scheme_flag + ))); + } + let mut sk_bytes = [0u8; 32]; + sk_bytes.copy_from_slice(&bytes[1..33]); + let private_key = Ed25519PrivateKey::new(sk_bytes); + let address = private_key.public_key().derive_address(); + Ok(Self { + private_key, + address, + }) + } + + /// Convenience: decode and return only the Sui address (`0x...` hex). + pub fn address_hex(&self) -> String { + self.address.to_string() + } +} + +/// Resolve `WALRUS_PACKAGE_ID` from env, falling back to network defaults. +pub fn resolve_walrus_package_id(network: &str) -> String { + std::env::var("WALRUS_PACKAGE_ID").unwrap_or_else(|_| { + match network { + "testnet" => WALRUS_PACKAGE_ID_TESTNET.to_string(), + // mainnet is the safe default for any unrecognized network too + _ => WALRUS_PACKAGE_ID_MAINNET.to_string(), + } + }) +} + +/// Build, sign, and execute the metadata-set + transfer PTB on Sui. +/// +/// On success returns the executed transaction digest (base58). +/// +/// Path A — Enoki sponsorship (when `enoki.is_configured()`): +/// 1. Build the PTB (no gas, no sender) and BCS-encode just `transaction.kind`. +/// 2. Call `enoki.sponsor(server_address, kind_b64)` → returns the +/// sponsored `TransactionData` bytes + digest. +/// 3. Sign the sponsored digest with the server key, base64-encode the +/// `UserSignature`, and call `enoki.sponsor_execute(digest, sig)`. +/// 4. Return the executed digest. +/// +/// Path B — Direct sign (Enoki unconfigured, OR sponsor errored and +/// `enoki_fallback == true`): +/// 1. Fetch the gas coin via `suix_getCoins` and the blob's owned-ref via +/// `sui_getObject`. +/// 2. Re-build the PTB with sender + gas + budget + price. +/// 3. BCS-serialize Transaction, sign with Ed25519 over the Sui intent +/// digest. +/// 4. Submit via `sui_executeTransactionBlock` with `WaitForLocalExecution`. +/// +/// If sponsor errors and `enoki_fallback == false` we surface the failure as +/// `OnchainError::EnokiSponsor` instead of falling back, matching the legacy +/// sidecar behavior of `executeWithEnokiSponsor` when +/// `ENOKI_FALLBACK_TO_DIRECT_SIGN=false`. +#[allow(clippy::too_many_arguments)] +pub async fn set_metadata_and_transfer( + http: &reqwest::Client, + sui_rpc_url: &str, + signer: &ServerSigner, + blob_object_id: &str, + walrus_package_id: &str, + namespace: &str, + target_owner: &str, + memwal_owner: &str, + memwal_package_id: &str, + agent_id: Option<&str>, + enoki: &crate::enoki::EnokiClient, + enoki_fallback: bool, +) -> Result { + // ── 1. Parse + validate inputs (shared by both paths) ────────────── + let walrus_pkg = Address::from_hex(walrus_package_id).map_err(|e| { + OnchainError::Address(format!("walrus_package_id={}: {}", walrus_package_id, e)) + })?; + let blob_id_addr = Address::from_hex(blob_object_id).map_err(|e| { + OnchainError::ObjectId(format!("blob_object_id={}: {}", blob_object_id, e)) + })?; + let target_addr = Address::from_hex(target_owner).map_err(|e| { + OnchainError::Address(format!("target_owner={}: {}", target_owner, e)) + })?; + + let rpc = SuiRpcClient::new(http.clone(), sui_rpc_url.to_string()); + let blob_obj_ref = rpc.get_object_ref(blob_object_id).await?; + + // ── 2. Path A: Enoki sponsorship (try first when configured) ─────── + if enoki.is_configured() { + match try_enoki_sponsor( + &rpc, + signer, + enoki, + walrus_pkg, + blob_id_addr, + &blob_obj_ref, + target_addr, + namespace, + memwal_owner, + memwal_package_id, + agent_id, + ) + .await + { + Ok(digest) => { + tracing::info!( + "walrus_onchain: metadata+transfer ok via Enoki blob={} -> {} digest={}", + blob_object_id, + target_owner, + digest, + ); + return Ok(digest); + } + Err(e) => { + if !enoki_fallback { + tracing::error!( + "[walrus-onchain] Enoki sponsor failed and fallback disabled: {}", + e, + ); + return Err(OnchainError::EnokiSponsor(e.to_string())); + } + tracing::warn!( + "[walrus-onchain] Enoki sponsor failed ({}); falling back to direct sign", + e, + ); + // fall through to Path B + } + } + } + + // ── 3. Path B: Direct sign with the server's own gas coin ────────── + let gas_coin_ref = rpc.get_first_gas_coin(&signer.address_hex()).await?; + + tracing::debug!( + "walrus_onchain: direct-sign signer={}, blob={}@{}, gas_coin={}@{}", + signer.address_hex(), + blob_obj_ref.object_id, + blob_obj_ref.version, + gas_coin_ref.object_id, + gas_coin_ref.version, + ); + + let mut tx = build_metadata_ptb( + walrus_pkg, + blob_id_addr, + &blob_obj_ref, + target_addr, + namespace, + memwal_owner, + memwal_package_id, + agent_id, + ); + + // Gas + sender + budget. + tx.set_sender(signer.address); + tx.set_gas_budget(DEFAULT_GAS_BUDGET); + tx.set_gas_price(DEFAULT_GAS_PRICE); + tx.add_gas_objects([ObjectInput::owned( + Address::from_hex(&gas_coin_ref.object_id) + .map_err(|e| OnchainError::ObjectId(format!("gas coin id: {}", e)))?, + gas_coin_ref.version, + gas_coin_ref.digest, + )]); + + let transaction = tx + .try_build() + .map_err(|e| OnchainError::Build(e.to_string()))?; + + // Sign the intent-prefixed digest (intent = TransactionData/V0/Sui). + let signing_digest = transaction.signing_digest(); + let user_sig: UserSignature = + >::try_sign(&signer.private_key, &signing_digest) + .map_err(|e| OnchainError::Sign(e.to_string()))?; + + // Submit via JSON-RPC. + let tx_bytes = bcs::to_bytes(&transaction).map_err(|e| OnchainError::Bcs(e.to_string()))?; + let tx_b64 = BASE64.encode(&tx_bytes); + let sig_b64 = BASE64.encode(user_sig.to_bytes()); + + let digest = rpc.execute_tx(&tx_b64, &sig_b64).await?; + tracing::info!( + "walrus_onchain: metadata+transfer ok via direct-sign blob={} -> {} digest={}", + blob_object_id, + target_owner, + digest, + ); + Ok(digest) +} + +// ============================================================ +// PTB construction (shared between Enoki + direct-sign paths) +// ============================================================ + +/// Build the metadata + transfer PTB **without** sender / gas. The Enoki path +/// needs only the `TransactionKind` bytes; the direct-sign path adds gas +/// before calling `try_build`. +#[allow(clippy::too_many_arguments)] +fn build_metadata_ptb( + walrus_pkg: Address, + blob_id_addr: Address, + blob_obj_ref: &ObjectRef, + target_addr: Address, + namespace: &str, + memwal_owner: &str, + memwal_package_id: &str, + agent_id: Option<&str>, +) -> TransactionBuilder { + let mut tx = TransactionBuilder::new(); + + let blob_arg = tx.object(ObjectInput::owned( + blob_id_addr, + blob_obj_ref.version, + blob_obj_ref.digest, + )); + + let module = Identifier::from_static("blob"); + let function = Identifier::from_static("insert_or_update_metadata_pair"); + + fn metadata_call( + tx: &mut TransactionBuilder, + walrus_pkg: Address, + module: &Identifier, + function: &Identifier, + blob_arg: sui_transaction_builder::Argument, + key: &str, + value: &str, + ) { + let key_arg = tx.pure(&key.to_string()); + let value_arg = tx.pure(&value.to_string()); + let f = Function::new(walrus_pkg, module.clone(), function.clone()); + let _ = tx.move_call(f, vec![blob_arg, key_arg, value_arg]); + } + + metadata_call(&mut tx, walrus_pkg, &module, &function, blob_arg, "memwal_namespace", namespace); + metadata_call(&mut tx, walrus_pkg, &module, &function, blob_arg, "memwal_owner", memwal_owner); + metadata_call(&mut tx, walrus_pkg, &module, &function, blob_arg, "memwal_package_id", memwal_package_id); + if let Some(aid) = agent_id { + if !aid.is_empty() { + metadata_call(&mut tx, walrus_pkg, &module, &function, blob_arg, "memwal_agent_id", aid); + } + } + + // Transfer the blob to the end user. + let recipient_arg = tx.pure(&target_addr); + tx.transfer_objects(vec![blob_arg], recipient_arg); + + tx +} + +// ============================================================ +// Enoki sponsorship path +// ============================================================ + +/// Build the PTB, BCS-encode just its `TransactionKind`, hand it to Enoki for +/// sponsorship, sign the sponsored bytes with the server key, and call +/// `sponsor_execute`. Returns the executed digest on success. +/// +/// Enoki accepts a `TransactionKind` and returns a fully-formed sponsored +/// `TransactionData`; the server only signs. +#[allow(clippy::too_many_arguments)] +async fn try_enoki_sponsor( + rpc: &SuiRpcClient, + signer: &ServerSigner, + enoki: &crate::enoki::EnokiClient, + walrus_pkg: Address, + blob_id_addr: Address, + blob_obj_ref: &ObjectRef, + target_addr: Address, + namespace: &str, + memwal_owner: &str, + memwal_package_id: &str, + agent_id: Option<&str>, +) -> Result { + // 1. Build the kind-only PTB. To get past `try_build`'s sender/gas + // requirement we attach a placeholder sender + a dummy gas object — + // the resulting `Transaction.kind` is what Enoki signs and pays for, + // so the placeholder values are immaterial. + let mut tx = build_metadata_ptb( + walrus_pkg, + blob_id_addr, + blob_obj_ref, + target_addr, + namespace, + memwal_owner, + memwal_package_id, + agent_id, + ); + tx.set_sender(signer.address); + tx.set_gas_budget(DEFAULT_GAS_BUDGET); + tx.set_gas_price(DEFAULT_GAS_PRICE); + tx.add_gas_objects([ObjectInput::owned( + Address::ZERO, + 0, + sui_sdk_types::Digest::ZERO, + )]); + let placeholder_tx = tx + .try_build() + .map_err(|e| OnchainError::Build(e.to_string()))?; + + // 2. BCS-encode only the `TransactionKind` — that's what Enoki accepts. + let kind_bytes = bcs::to_bytes(&placeholder_tx.kind) + .map_err(|e| OnchainError::Bcs(e.to_string()))?; + let kind_b64 = BASE64.encode(&kind_bytes); + + // 3. Sponsor. Pass [server, target_owner] as `allowedAddresses` so Enoki + // permits the user wallet as a recipient of the `transfer_objects` + // call. Without this, Enoki returns "Address ... is not allow-listed + // for receiving transfers" because only the team's pre-allow-listed + // addresses are accepted by default. + let sender_hex = signer.address_hex(); + let target_hex = format!("0x{}", hex::encode(target_addr.into_inner())); + let allow = [sender_hex.as_str(), target_hex.as_str()]; + let sponsored = enoki + .sponsor(&sender_hex, &kind_b64, &allow) + .await + .map_err(|e| OnchainError::Rpc(format!("Enoki sponsor: {}", e)))?; + + // 4. Decode sponsored bytes → Transaction → sign over its signing digest. + let sponsored_bytes = base64::engine::general_purpose::STANDARD + .decode(&sponsored.bytes) + .map_err(|e| OnchainError::Bcs(format!("decode sponsored bytes: {}", e)))?; + let sponsored_tx: sui_sdk_types::Transaction = bcs::from_bytes(&sponsored_bytes) + .map_err(|e| OnchainError::Bcs(format!("parse sponsored Transaction: {}", e)))?; + let signing_digest = sponsored_tx.signing_digest(); + let user_sig: UserSignature = + >::try_sign(&signer.private_key, &signing_digest) + .map_err(|e| OnchainError::Sign(e.to_string()))?; + let sig_b64 = BASE64.encode(user_sig.to_bytes()); + + // 5. Execute via Enoki. + let executed = enoki + .sponsor_execute(&sponsored.digest, &sig_b64) + .await + .map_err(|e| OnchainError::Rpc(format!("Enoki sponsor_execute: {}", e)))?; + + // Belt-and-suspenders: confirm the digest didn't drift in transit. + if executed.digest != sponsored.digest { + tracing::debug!( + "walrus_onchain: enoki executed digest ({}) differs from sponsored digest ({})", + executed.digest, + sponsored.digest, + ); + } + + // Suppress unused-rpc warning when Enoki path is taken (the rpc handle is + // still needed to fetch blob_obj_ref upstream). + let _ = rpc; + + Ok(executed.digest) +} + +// ============================================================ +// Sui JSON-RPC client (just what we need) +// ============================================================ + +#[derive(Debug, Clone)] +struct ObjectRef { + object_id: String, + version: u64, + digest: Digest, +} + +struct SuiRpcClient { + http: reqwest::Client, + url: String, +} + +impl SuiRpcClient { + fn new(http: reqwest::Client, url: String) -> Self { + Self { http, url } + } + + async fn rpc(&self, method: &str, params: serde_json::Value) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + let resp = self + .http + .post(&self.url) + .timeout(Duration::from_secs(SUI_RPC_TIMEOUT_SECS)) + .json(&body) + .send() + .await + .map_err(|e| OnchainError::Rpc(format!("{} HTTP failed: {}", method, e)))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| OnchainError::Rpc(format!("{} read body: {}", method, e)))?; + if !status.is_success() { + return Err(OnchainError::Rpc(format!( + "{} HTTP {}: {}", + method, status, text + ))); + } + let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| { + OnchainError::Rpc(format!("{} JSON parse: {} (body={})", method, e, text)) + })?; + if let Some(err) = v.get("error") { + return Err(OnchainError::Rpc(format!("{} returned error: {}", method, err))); + } + Ok(v) + } + + /// `suix_getCoins` for SUI — return the first owned coin we see. + async fn get_first_gas_coin(&self, owner: &str) -> Result { + let v = self + .rpc( + "suix_getCoins", + serde_json::json!([owner, "0x2::sui::SUI", null, 1]), + ) + .await?; + let coin = v + .pointer("/result/data/0") + .ok_or_else(|| OnchainError::NoGasCoin(owner.to_string()))?; + let object_id = coin + .get("coinObjectId") + .and_then(|x| x.as_str()) + .ok_or_else(|| OnchainError::Rpc("coin missing coinObjectId".into()))? + .to_string(); + let version = coin + .get("version") + .and_then(|x| x.as_str()) + .ok_or_else(|| OnchainError::Rpc("coin missing version".into()))? + .parse::() + .map_err(|e| OnchainError::Rpc(format!("coin version parse: {}", e)))?; + let digest_str = coin + .get("digest") + .and_then(|x| x.as_str()) + .ok_or_else(|| OnchainError::Rpc("coin missing digest".into()))?; + let digest = Digest::from_base58(digest_str) + .map_err(|e| OnchainError::Rpc(format!("coin digest parse: {}", e)))?; + Ok(ObjectRef { + object_id, + version, + digest, + }) + } + + /// `sui_getObject` with `showOwner: true` — return owned-ref triple. + async fn get_object_ref(&self, object_id: &str) -> Result { + let v = self + .rpc( + "sui_getObject", + serde_json::json!([object_id, { "showOwner": true }]), + ) + .await?; + let data = v + .pointer("/result/data") + .ok_or_else(|| OnchainError::ObjectNotFound(object_id.to_string()))?; + let version = data + .get("version") + .and_then(|x| x.as_str()) + .ok_or_else(|| OnchainError::Rpc("object missing version".into()))? + .parse::() + .map_err(|e| OnchainError::Rpc(format!("object version parse: {}", e)))?; + let digest_str = data + .get("digest") + .and_then(|x| x.as_str()) + .ok_or_else(|| OnchainError::Rpc("object missing digest".into()))?; + let digest = Digest::from_base58(digest_str) + .map_err(|e| OnchainError::Rpc(format!("object digest parse: {}", e)))?; + Ok(ObjectRef { + object_id: object_id.to_string(), + version, + digest, + }) + } + + /// `sui_executeTransactionBlock` with `WaitForLocalExecution` — return the digest. + async fn execute_tx(&self, tx_b64: &str, sig_b64: &str) -> Result { + let v = self + .rpc( + "sui_executeTransactionBlock", + serde_json::json!([ + tx_b64, + [sig_b64], + { "showEffects": true }, + "WaitForLocalExecution", + ]), + ) + .await?; + // Effects.status — fail loudly on Move abort + if let Some(status) = v.pointer("/result/effects/status/status").and_then(|x| x.as_str()) { + if status != "success" { + let err = v + .pointer("/result/effects/status/error") + .and_then(|x| x.as_str()) + .unwrap_or("(no error msg)"); + return Err(OnchainError::Execute(format!( + "tx status={}, error={}", + status, err + ))); + } + } + let digest = v + .pointer("/result/digest") + .and_then(|x| x.as_str()) + .ok_or_else(|| OnchainError::Execute("response missing digest".into()))? + .to_string(); + Ok(digest) + } +} + +// ============================================================ +// Tests +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_pkg_id_mainnet_default() { + // Don't depend on env var being unset — set explicitly to verify override path. + std::env::remove_var("WALRUS_PACKAGE_ID"); + assert_eq!( + resolve_walrus_package_id("mainnet"), + WALRUS_PACKAGE_ID_MAINNET + ); + } + + #[test] + fn resolve_pkg_id_unknown_falls_back_to_mainnet() { + std::env::remove_var("WALRUS_PACKAGE_ID"); + assert_eq!( + resolve_walrus_package_id("zalgonet"), + WALRUS_PACKAGE_ID_MAINNET + ); + } + + #[test] + fn private_key_decode_rejects_garbage() { + let err = ServerSigner::from_suiprivkey("not-bech32").unwrap_err(); + assert!(matches!(err, OnchainError::PrivateKey(_))); + } + + #[test] + fn private_key_decode_rejects_wrong_hrp() { + // `bc1...` is a Bitcoin bech32 address — wrong HRP. + let err = ServerSigner::from_suiprivkey( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + ) + .unwrap_err(); + assert!(matches!(err, OnchainError::PrivateKey(_))); + } + + // ── ENG-1700: Enoki path tests ───────────────────────────────────── + + /// Regression: BCS-encoding an empty `ProgrammableTransaction` kind must + /// produce a small, deterministic byte sequence. This pins the on-wire + /// format: tag (1 byte) for `ProgrammableTransaction` variant, followed + /// by a uleb128(0) input count and uleb128(0) command count — i.e. + /// `[0x00, 0x00, 0x00]`. If this changes, Enoki sponsorship will fail + /// silently with malformed bytes. + #[test] + fn transaction_kind_bcs_empty_ptb_known_bytes() { + let kind = sui_sdk_types::TransactionKind::ProgrammableTransaction( + sui_sdk_types::ProgrammableTransaction { + inputs: vec![], + commands: vec![], + }, + ); + let bytes = bcs::to_bytes(&kind).expect("bcs encode kind"); + // Variant tag for ProgrammableTransaction is 0; followed by len=0 + // for inputs and len=0 for commands. + assert_eq!(bytes, vec![0x00, 0x00, 0x00]); + } + + /// When the EnokiClient is unconfigured (`is_configured() == false`), + /// `set_metadata_and_transfer` must skip the Enoki path entirely and + /// proceed to direct-sign. We trigger the failure at the very first + /// step of direct-sign (`get_object_ref` over a bogus RPC URL) so a + /// fast network error confirms we did NOT try Enoki — if we had, the + /// error would mention "Enoki" in its display. + #[tokio::test] + async fn set_metadata_skips_enoki_when_unconfigured() { + // Bogus RPC URL → connection refused immediately, no retry storm. + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .unwrap(); + // 127.0.0.1:1 is reserved & unbound on every reasonable host. + let rpc_url = "http://127.0.0.1:1"; + + // A throwaway signer (private key bytes are arbitrary — we never + // actually sign). + let sk = sui_crypto::ed25519::Ed25519PrivateKey::new([7u8; 32]); + let addr = sk.public_key().derive_address(); + let signer = ServerSigner { + private_key: sk, + address: addr, + }; + + let enoki = crate::enoki::EnokiClient::new(None, "mainnet".into()); + assert!(!enoki.is_configured()); + + let res = set_metadata_and_transfer( + &http, + rpc_url, + &signer, + "0x0000000000000000000000000000000000000000000000000000000000000001", + WALRUS_PACKAGE_ID_MAINNET, + "ns", + // target_owner — any valid 0x address + "0x0000000000000000000000000000000000000000000000000000000000000002", + // memwal_owner + "0x0000000000000000000000000000000000000000000000000000000000000002", + // memwal_package_id + "0xdeadbeef", + None, + &enoki, + true, // fallback enabled (irrelevant here — enoki unconfigured) + ) + .await; + + let err = res.expect_err("must fail — bogus RPC"); + let msg = err.to_string(); + // Enoki was unconfigured, so we must hit the direct-sign path's first + // RPC call (`get_object_ref` -> `Rpc(...)` / `ObjectNotFound(...)`). + assert!( + !msg.to_lowercase().contains("enoki"), + "Enoki path should not have been attempted; got: {}", + msg, + ); + } +} diff --git a/services/server/src/walrus_publisher.rs b/services/server/src/walrus_publisher.rs new file mode 100644 index 00000000..13d46195 --- /dev/null +++ b/services/server/src/walrus_publisher.rs @@ -0,0 +1,215 @@ +//! Walrus publisher HTTP client (ENG-1700 / Phase 3). +//! +//! Replaces the TS sidecar `writeBlobFlow` call with a direct +//! `PUT {WALRUS_PUBLISHER_URL}/v1/blobs?epochs=N&send_object_to=` +//! against a public Walrus publisher. +//! +//! # Why this is correct +//! +//! Mysten's public publishers (`https://publisher.walrus-mainnet.walrus.space` +//! and the testnet equivalent) accept a raw byte body and: +//! 1. Encode the blob, +//! 2. Pay for `epochs` epochs of storage, +//! 3. Register + certify the Walrus `Blob` object, +//! 4. Transfer the resulting `Blob` object to `send_object_to` if set. +//! +//! The publisher returns either: +//! - `{"newlyCreated": {"blobObject": {"id": "0x...", "blobId": "...", ...}, ...}}` +//! (first time the content was uploaded), or +//! - `{"alreadyCertified": {"blobId": "...", "endEpoch": ..., ...}}` +//! (somebody already paid for storage of an identical blob; in this case +//! there is no fresh `Blob` object owned by us). +//! +//! In the `alreadyCertified` branch we cannot run the metadata-set + transfer +//! PTB (we don't own a `Blob` object), so we return `object_id = None` and +//! let the caller decide. For MemWal's flow each ciphertext is unique +//! (SEAL is non-deterministic), so this branch is effectively unreachable +//! in production but handled defensively. + +use std::time::Duration; + +const PUBLISHER_TIMEOUT_SECS: u64 = 60; + +#[derive(Debug, thiserror::Error)] +pub enum PublisherError { + #[error("publisher network error: {0}")] + Network(String), + #[error("publisher returned HTTP {status}: {body}")] + Status { status: u16, body: String }, + #[error("publisher response decode failed: {0}")] + Decode(String), + #[error("publisher returned alreadyCertified for a unique upload (no Blob object owned by us)")] + AlreadyCertifiedNoObject, +} + +/// Outcome of `PUT /v1/blobs`. +#[derive(Debug, Clone)] +pub struct PublishedBlob { + /// Walrus content-addressed blob ID (base64url). + pub blob_id: String, + /// Sui object ID of the freshly minted `Blob` Move object (`0x...`), + /// when the upload was newly created. `None` for `alreadyCertified`. + pub object_id: Option, +} + +/// `PUT {publisher_url}/v1/blobs?epochs=N&send_object_to=` with raw bytes. +/// +/// `epochs` is capped by the publisher's policy; we additionally cap to 5 +/// upstream in `walrus.rs` to prevent runaway storage spend. +/// +/// `send_object_to` *should* be the server signer's Sui address — the +/// publisher transfers the Blob there so we can subsequently set metadata +/// and re-transfer it to the end user inside our own PTB. +pub async fn upload_blob_via_publisher( + http: &reqwest::Client, + publisher_url: &str, + data: &[u8], + epochs: u64, + send_object_to: &str, +) -> Result { + let url = format!( + "{}/v1/blobs?epochs={}&send_object_to={}", + publisher_url.trim_end_matches('/'), + epochs, + send_object_to, + ); + + tracing::debug!( + "walrus publisher PUT: url={}, body_len={}, epochs={}", + url, + data.len(), + epochs, + ); + + // We deliberately build a per-call client with a long timeout. The + // shared `state.http_client` is 30s, but Walrus publisher uploads + // routinely take longer for non-trivial blob sizes. + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(PUBLISHER_TIMEOUT_SECS)) + .build() + .map_err(|e| PublisherError::Network(format!("build client: {}", e)))?; + // We accept either the shared client or a fresh one — prefer shared for + // connection pooling, but fall back when the caller passes one with a + // short timeout. The trait object is small so this branch is cheap. + let _ = http; // keep param for API symmetry; future callers may swap it in + let resp = client + .put(&url) + .body(data.to_vec()) + .send() + .await + .map_err(|e| PublisherError::Network(e.to_string()))?; + + let status = resp.status(); + let body_text = resp + .text() + .await + .map_err(|e| PublisherError::Network(format!("read body: {}", e)))?; + + if !status.is_success() { + return Err(PublisherError::Status { + status: status.as_u16(), + body: body_text, + }); + } + + parse_publisher_response(&body_text) +} + +/// Pure parser for unit-testing without a live publisher. +fn parse_publisher_response(body: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(body) + .map_err(|e| PublisherError::Decode(format!("not JSON: {} (body={})", e, body)))?; + + // Branch 1: newlyCreated.blobObject.{id,blobId} + if let Some(newly) = v.get("newlyCreated") { + let blob_obj = newly + .get("blobObject") + .ok_or_else(|| PublisherError::Decode("missing newlyCreated.blobObject".into()))?; + let blob_id = blob_obj + .get("blobId") + .and_then(|x| x.as_str()) + .ok_or_else(|| PublisherError::Decode("missing newlyCreated.blobObject.blobId".into()))? + .to_string(); + let object_id = blob_obj + .get("id") + .and_then(|x| x.as_str()) + .ok_or_else(|| PublisherError::Decode("missing newlyCreated.blobObject.id".into()))? + .to_string(); + return Ok(PublishedBlob { + blob_id, + object_id: Some(object_id), + }); + } + + // Branch 2: alreadyCertified.blobId (no Blob object minted for us) + if let Some(already) = v.get("alreadyCertified") { + let blob_id = already + .get("blobId") + .and_then(|x| x.as_str()) + .ok_or_else(|| PublisherError::Decode("missing alreadyCertified.blobId".into()))? + .to_string(); + // We deliberately surface this as an error — without owning a Blob + // object, the caller cannot subsequently set metadata or transfer. + // For SEAL-encrypted blobs this branch is effectively unreachable + // (each ciphertext is unique), but flag it loudly if it ever fires. + tracing::warn!( + "walrus publisher returned alreadyCertified blob_id={}, no Blob object minted", + blob_id, + ); + return Err(PublisherError::AlreadyCertifiedNoObject); + } + + Err(PublisherError::Decode(format!( + "unexpected publisher response shape (no newlyCreated/alreadyCertified): {}", + body, + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_newly_created() { + let body = serde_json::json!({ + "newlyCreated": { + "blobObject": { + "id": "0x1234abcd", + "blobId": "AbCdEfGh-base64url", + "registeredEpoch": 100 + }, + "resourceOperation": {} + } + }) + .to_string(); + let r = parse_publisher_response(&body).unwrap(); + assert_eq!(r.blob_id, "AbCdEfGh-base64url"); + assert_eq!(r.object_id.as_deref(), Some("0x1234abcd")); + } + + #[test] + fn parse_already_certified_errors() { + let body = serde_json::json!({ + "alreadyCertified": { + "blobId": "AbCdEfGh", + "endEpoch": 200 + } + }) + .to_string(); + let err = parse_publisher_response(&body).unwrap_err(); + assert!(matches!(err, PublisherError::AlreadyCertifiedNoObject)); + } + + #[test] + fn parse_garbage_errors() { + let err = parse_publisher_response("not json").unwrap_err(); + assert!(matches!(err, PublisherError::Decode(_))); + } + + #[test] + fn parse_unexpected_shape_errors() { + let body = r#"{"foo": "bar"}"#; + let err = parse_publisher_response(body).unwrap_err(); + assert!(matches!(err, PublisherError::Decode(_))); + } +} From 7655ce11899c604da4b177f93772ad1bb270bb49 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Tue, 5 May 2026 22:36:01 +0700 Subject: [PATCH 2/4] ci: drop dead benchmark workflows after sidecar removal (ENG-1700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit benchmark-smoke.yml and benchmark-live.yml drove the now-deleted services/server/scripts/bench-recall-latency.ts. Both fail at actions/setup-node because services/server/scripts/package-lock.json no longer exists. The benchmark target itself is gone with the sidecar; restore in a follow-up ticket once a Rust-native benchmark driver lands. docs/relayer/benchmark-ci-setup.md still references these files but is out of scope to rewrite here — flagged for the follow-up. --- .github/workflows/benchmark-live.yml | 146 -------------------------- .github/workflows/benchmark-smoke.yml | 68 ------------ 2 files changed, 214 deletions(-) delete mode 100644 .github/workflows/benchmark-live.yml delete mode 100644 .github/workflows/benchmark-smoke.yml diff --git a/.github/workflows/benchmark-live.yml b/.github/workflows/benchmark-live.yml deleted file mode 100644 index e8cda913..00000000 --- a/.github/workflows/benchmark-live.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Benchmark Live - -on: - push: - branches: - - staging - - dev - workflow_dispatch: - inputs: - target_environment: - description: GitHub benchmark environment to use - required: true - type: choice - default: staging - options: - - dev - - staging - server_url: - description: MemWal server URL. Empty uses BENCH_SERVER_URL environment variable. - required: false - default: '' - namespace: - description: Memory namespace - required: false - default: benchmark - remember_text: - description: Remember benchmark text - required: false - default: benchmark memory - query: - description: Recall query text - required: false - default: benchmark memory - limit: - description: Top-K recall result limit - required: false - default: '5' - remember_runs: - description: Remember runs - required: false - default: '3' - cold_runs: - description: Cold-path runs - required: false - default: '3' - warm_runs: - description: Warm-path runs - required: false - default: '10' - schedule: - - cron: '0 9 * * 1' - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - memory-api: - name: Memory API Latency - runs-on: ubuntu-latest - environment: - name: benchmark-${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || (github.ref_name == 'staging' && 'staging' || 'dev') }} - timeout-minutes: 30 - - env: - SERVER_URL: ${{ github.event.inputs.server_url || vars.BENCH_SERVER_URL }} - NAMESPACE: ${{ github.event.inputs.namespace || 'benchmark' }} - REMEMBER_TEXT: ${{ github.event.inputs.remember_text || 'benchmark memory' }} - QUERY: ${{ github.event.inputs.query || 'benchmark memory' }} - LIMIT: ${{ github.event.inputs.limit || '5' }} - REMEMBER_RUNS: ${{ github.event.inputs.remember_runs || '3' }} - COLD_RUNS: ${{ github.event.inputs.cold_runs || '3' }} - WARM_RUNS: ${{ github.event.inputs.warm_runs || '10' }} - BENCH_DELEGATE_KEY: ${{ secrets.BENCH_DELEGATE_KEY }} - BENCH_ACCOUNT_ID: ${{ secrets.BENCH_ACCOUNT_ID }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: npm - cache-dependency-path: services/server/scripts/package-lock.json - - - name: Install sidecar script dependencies - working-directory: services/server/scripts - run: npm ci - - - name: Run memory API latency benchmark - shell: bash - run: | - set -euo pipefail - - missing=0 - for name in SERVER_URL BENCH_DELEGATE_KEY BENCH_ACCOUNT_ID; do - if [ -z "${!name:-}" ]; then - echo "::error::${name} is required" - missing=1 - fi - done - if [ "$missing" -ne 0 ]; then - exit 1 - fi - - mkdir -p benchmark-results - services/server/scripts/node_modules/.bin/tsx services/server/scripts/bench-recall-latency.ts \ - --server-url "$SERVER_URL" \ - --delegate-key "$BENCH_DELEGATE_KEY" \ - --account-id "$BENCH_ACCOUNT_ID" \ - --remember-text "$REMEMBER_TEXT" \ - --query "$QUERY" \ - --namespace "$NAMESPACE" \ - --limit "$LIMIT" \ - --remember-runs "$REMEMBER_RUNS" \ - --cold-runs "$COLD_RUNS" \ - --warm-runs "$WARM_RUNS" \ - --output benchmark-results/memory-api.json \ - --no-color - - - name: Write benchmark summary - if: always() - shell: bash - run: | - if [ ! -f benchmark-results/memory-api.json ]; then - echo "No benchmark result file was produced." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - node <<'NODE' >> "$GITHUB_STEP_SUMMARY" - const fs = require('fs'); - const json = JSON.parse(fs.readFileSync('benchmark-results/memory-api.json', 'utf8')); - console.log('## memory API benchmark'); - console.log(''); - console.log(json.markdownTable || 'No markdown table found in result JSON.'); - console.log(''); - NODE - - - name: Upload benchmark artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: benchmark-results-${{ github.run_id }} - path: benchmark-results/memory-api.json - if-no-files-found: warn - retention-days: 14 diff --git a/.github/workflows/benchmark-smoke.yml b/.github/workflows/benchmark-smoke.yml deleted file mode 100644 index f157a44f..00000000 --- a/.github/workflows/benchmark-smoke.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Benchmark Smoke - -on: - pull_request: - paths: - - 'services/server/scripts/**' - - '.github/workflows/benchmark-live.yml' - - '.github/workflows/benchmark-smoke.yml' - - 'docs/relayer/benchmark-ci-setup.md' - push: - branches: - - main - - staging - - dev - paths: - - 'services/server/scripts/**' - - '.github/workflows/benchmark-live.yml' - - '.github/workflows/benchmark-smoke.yml' - - 'docs/relayer/benchmark-ci-setup.md' - workflow_dispatch: - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - smoke: - name: Compile & CLI Smoke - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust - uses: Swatinem/rust-cache@v2 - with: - workspaces: services/server - - - name: Cargo check - working-directory: services/server - run: cargo check - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: npm - cache-dependency-path: services/server/scripts/package-lock.json - - - name: Install sidecar script dependencies - working-directory: services/server/scripts - run: npm ci - - - name: Typecheck benchmark scripts - working-directory: services/server/scripts - run: | - ./node_modules/.bin/tsc --noEmit --skipLibCheck \ - --target ES2022 \ - --module NodeNext \ - --moduleResolution NodeNext \ - bench-recall-latency.ts - - - name: Benchmark CLI help smoke - working-directory: services/server/scripts - run: | - ./node_modules/.bin/tsx bench-recall-latency.ts --help From 33422e75e7397447386febd450b287f485295910 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 6 May 2026 11:01:24 +0700 Subject: [PATCH 3/4] fix(server): align Walrus upload env var with sidecar (ENG-1700) Match the legacy Node sidecar's env contract (`sidecar-server.ts:65-69`) so Railway's dev/staging/mainnet env values keep working without rename: - Read `WALRUS_UPLOAD_RELAY_URL` (was: `WALRUS_PUBLISHER_URL`, a name we invented and that no env actually sets). - Per-network defaults: `upload-relay.{testnet,mainnet}.walrus.space`. - Same per-network Sui RPC fallback factored into one helper. Known gap (ENG-1700 follow-up): the default `upload-relay.*` endpoint speaks the multi-step register/upload/certify relay protocol used by @mysten/walrus, while `walrus_publisher.rs` currently only speaks the simpler `PUT /v1/blobs` public-publisher protocol. Until the relay protocol is ported (Path A), `WALRUS_UPLOAD_RELAY_URL` must be set to a `publisher.walrus-{net}.walrus.space` endpoint. Inline doc + helper explain. --- services/server/src/walrus.rs | 63 ++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index a22edbb1..080d0673 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -76,19 +76,12 @@ pub async fn upload_blob( // LOW-17 parity: cap epochs at 5 to prevent accidental large storage spend. let capped_epochs = epochs.min(5); - // Resolve sui rpc + walrus publisher + walrus package id from env. We - // can't reach into AppState from here without a wider refactor, so + // Resolve sui rpc + walrus upload endpoint + walrus package id from env. + // We can't reach into AppState from here without a wider refactor, so // pull from env directly (matches the existing pattern in routes). - let sui_rpc_url = std::env::var("SUI_RPC_URL").unwrap_or_else(|_| { - match std::env::var("SUI_NETWORK").as_deref() { - Ok("testnet") => "https://fullnode.testnet.sui.io:443".to_string(), - Ok("devnet") => "https://fullnode.devnet.sui.io:443".to_string(), - _ => "https://fullnode.mainnet.sui.io:443".to_string(), - } - }); - let publisher_url = std::env::var("WALRUS_PUBLISHER_URL") - .unwrap_or_else(|_| "https://publisher.walrus-mainnet.walrus.space".to_string()); let network = std::env::var("SUI_NETWORK").unwrap_or_else(|_| "mainnet".to_string()); + let sui_rpc_url = sui_rpc_url_from_network(&network); + let upload_url = walrus_upload_url_from_env(&network); let walrus_pkg = walrus_onchain::resolve_walrus_package_id(&network); // Step 1: load signer from KeyPool via env (KeyPool itself is on AppState @@ -105,7 +98,7 @@ pub async fn upload_blob( // Step 2: publish to Walrus let published = walrus_publisher::upload_blob_via_publisher( client, - &publisher_url, + &upload_url, data, capped_epochs, &server_address, @@ -169,6 +162,44 @@ pub async fn upload_blob( }) } +/// Resolve the Sui JSON-RPC fullnode URL for `network`. Mirrors the +/// per-network fallback used in the legacy sidecar (`getJsonRpcFullnodeUrl`). +fn sui_rpc_url_from_network(network: &str) -> String { + if let Ok(v) = std::env::var("SUI_RPC_URL") { + return v; + } + match network { + "testnet" => "https://fullnode.testnet.sui.io:443".to_string(), + "devnet" => "https://fullnode.devnet.sui.io:443".to_string(), + _ => "https://fullnode.mainnet.sui.io:443".to_string(), + } +} + +/// Resolve the Walrus upload endpoint URL. +/// +/// Reads `WALRUS_UPLOAD_RELAY_URL` env var, falling back to the per-network +/// upload-relay default — matches the legacy sidecar +/// (`scripts/sidecar-server.ts:65-69`) so Railway's dev/staging/mainnet env +/// values keep working without rename. +/// +/// **NOTE — protocol gap (ENG-1700 follow-up):** the upload-relay default +/// URLs (`upload-relay.{net}.walrus.space`) speak the multi-step +/// register/upload/certify relay protocol used by `@mysten/walrus`. The +/// current implementation in `walrus_publisher.rs` only speaks the simpler +/// `PUT /v1/blobs` public-publisher protocol, so it will fail against the +/// default relay URL. Until the relay protocol is ported (Path A), set +/// `WALRUS_UPLOAD_RELAY_URL` to a `publisher.walrus-{net}.walrus.space` +/// endpoint instead. +fn walrus_upload_url_from_env(network: &str) -> String { + if let Ok(v) = std::env::var("WALRUS_UPLOAD_RELAY_URL") { + return v; + } + match network { + "testnet" => "https://upload-relay.testnet.walrus.space".to_string(), + _ => "https://upload-relay.mainnet.walrus.space".to_string(), + } +} + fn load_pool_keys_from_env() -> Vec { if let Ok(s) = std::env::var("SERVER_SUI_PRIVATE_KEYS") { let v: Vec = s @@ -216,14 +247,8 @@ pub async fn query_blobs_by_owner( namespace: Option<&str>, package_id: Option<&str>, ) -> Result, AppError> { - let sui_rpc_url = std::env::var("SUI_RPC_URL").unwrap_or_else(|_| { - match std::env::var("SUI_NETWORK").as_deref() { - Ok("testnet") => "https://fullnode.testnet.sui.io:443".to_string(), - Ok("devnet") => "https://fullnode.devnet.sui.io:443".to_string(), - _ => "https://fullnode.mainnet.sui.io:443".to_string(), - } - }); let network = std::env::var("SUI_NETWORK").unwrap_or_else(|_| "mainnet".to_string()); + let sui_rpc_url = sui_rpc_url_from_network(&network); let walrus_pkg = walrus_onchain::resolve_walrus_package_id(&network); let walrus_blob_type = format!("{}::blob::Blob", walrus_pkg); From ec9d2dc6e3cc90ac6d32d610f0b7907032933808 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 6 May 2026 12:25:35 +0700 Subject: [PATCH 4/4] fix(server): keep WALRUS_PUBLISHER_URL env name, drop dead per-network fallback (ENG-1700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the env-name change from 33422e7. Railway sets `WALRUS_PUBLISHER_URL` across dev/staging/mainnet (verified against the production Raw Editor): e.g. `WALRUS_PUBLISHER_URL=https://publisher.walrus-mainnet.walrus.space` on production. The previous commit renamed the read to `WALRUS_UPLOAD_RELAY_URL` (sidecar's internal var name, NOT what Railway exports), which made the server fall back to the per-network upload-relay default — an endpoint that doesn't accept `PUT /v1/blobs` and returned 404 on every upload. Also drops the speculative testnet/mainnet branching default added in 33422e7. The single-line read with one fallback (matching the original pre-33422e7 code) is enough — Railway always sets the env var. --- services/server/src/walrus.rs | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index 080d0673..8d8489ce 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -81,7 +81,8 @@ pub async fn upload_blob( // pull from env directly (matches the existing pattern in routes). let network = std::env::var("SUI_NETWORK").unwrap_or_else(|_| "mainnet".to_string()); let sui_rpc_url = sui_rpc_url_from_network(&network); - let upload_url = walrus_upload_url_from_env(&network); + let upload_url = std::env::var("WALRUS_PUBLISHER_URL") + .unwrap_or_else(|_| "https://publisher.walrus-mainnet.walrus.space".to_string()); let walrus_pkg = walrus_onchain::resolve_walrus_package_id(&network); // Step 1: load signer from KeyPool via env (KeyPool itself is on AppState @@ -175,31 +176,6 @@ fn sui_rpc_url_from_network(network: &str) -> String { } } -/// Resolve the Walrus upload endpoint URL. -/// -/// Reads `WALRUS_UPLOAD_RELAY_URL` env var, falling back to the per-network -/// upload-relay default — matches the legacy sidecar -/// (`scripts/sidecar-server.ts:65-69`) so Railway's dev/staging/mainnet env -/// values keep working without rename. -/// -/// **NOTE — protocol gap (ENG-1700 follow-up):** the upload-relay default -/// URLs (`upload-relay.{net}.walrus.space`) speak the multi-step -/// register/upload/certify relay protocol used by `@mysten/walrus`. The -/// current implementation in `walrus_publisher.rs` only speaks the simpler -/// `PUT /v1/blobs` public-publisher protocol, so it will fail against the -/// default relay URL. Until the relay protocol is ported (Path A), set -/// `WALRUS_UPLOAD_RELAY_URL` to a `publisher.walrus-{net}.walrus.space` -/// endpoint instead. -fn walrus_upload_url_from_env(network: &str) -> String { - if let Ok(v) = std::env::var("WALRUS_UPLOAD_RELAY_URL") { - return v; - } - match network { - "testnet" => "https://upload-relay.testnet.walrus.space".to_string(), - _ => "https://upload-relay.mainnet.walrus.space".to_string(), - } -} - fn load_pool_keys_from_env() -> Vec { if let Ok(s) = std::env::var("SERVER_SUI_PRIVATE_KEYS") { let v: Vec = s